Files
cnx_vinyl/docs/fleet-calculator.html
grabowski face699a28
Deploy test site / deploy (push) Successful in 57s
Optical QC: EtherCAT control plan with geizhals.de prices
User, 19 Oct 2026: plan the current EtherCAT setup, keep the 100 W servo,
check prices on geizhals.de.

- New docs/equipment/inline-qc-control-plan.md: decisions, bus layout
  (drives at the end of the line because the A6-200EC takes control
  power from the main supply), I/O list for EL1809/EL2809/EL3152/EL5101
  and the drive touch probe, PDO and DC notes from the A6-EC manual,
  geizhals.de price table (EUR 1,891.60 to 2,104.39 + USD 246.58 for the
  servo kits, THB 80k to 88k), NixOS/EtherCrab software, safety circuit
  (PNOZ s3, two contactors, dump valve), bench test with pass marks
  (parts about THB 64k, all reused), build order, spares, open points.
- EL3102 replaced by EL3152: the optoNCDT 1420 output is 4 to 20 mA.
- Spindle encoder index goes to an EL5101 instead of the drive touch
  probe; encoder 2,000 to 2,500 pulses.
- BOM: control lines split per part with geizhals.de prices (+30% high
  end for shipping and Thai duty/VAT, estimate); total USD 16,912 to
  33,061 (THB 0.56M to 1.09M). Doc, render caption and fleet calculator
  text now say USD 17k to 33k.
- README links the plan.
2026-10-19 10:43:59 +07:00

4087 lines
295 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<title>CNX Vinyl · plant and business plan calculator</title>
<script>
(function(){var t=null;try{t=localStorage.getItem("cnx-theme")}catch(e){}
if(t!=="light"&&t!=="dark")t=window.matchMedia&&matchMedia("(prefers-color-scheme: light)").matches?"light":"dark";
document.documentElement.setAttribute("data-theme",t);})();
</script>
<style>
:root {
--bg: #0a0f1a; --panel: #0f1626; --panel2: #131c30; --stroke: #2a3a56;
--line: #1c2840; --fg: #d6deeb; --mute: #8a97b0; --accent: #3ddc84;
--warn: #ffb547; --bad: #ff6b6b; --dspec: #c58bff; --dstd: #6aa8ff;
--classic: #3ddc84;
}
:root { color-scheme: dark; --input: #0a1120; --flagw: #1b1a12; --flago: #0f1a14; --flagb: #1d1214; --cur: #12261c;
--bv: #9fd0ff; --bvb: #2c4c73; --bd: #b5e8c7; --bdb: #2b5a3d; --be: #ffd59a; --beb: #6b5426; --bu: #e2c4ff; --bub: #563a73; }
html[data-theme=light] {
color-scheme: light;
--bg: #f4f6fa; --panel: #ffffff; --panel2: #eef2f8; --stroke: #cfd8e6; --line: #e4e9f1;
--fg: #1b2533; --mute: #5b6880; --accent: #0e8f4f; --warn: #c27c00; --bad: #c62828;
--dspec: #9b5de5; --dstd: #2f7de1; --classic: #1fae68;
--input: #ffffff; --flagw: #fff6e0; --flago: #e8f6ee; --flagb: #fdeaea; --cur: #e1f3e8;
--bv: #1f5f9e; --bvb: #9cc0e6; --bd: #1e7a45; --bdb: #9fd3b3; --be: #8a5a00; --beb: #e3c27a; --bu: #6b3fa0; --bub: #c9a8ec;
}
:root { --pk-press: #2a3a56; --pk-pressS: #6d86b3; --pk-glyph: #3a4d70; }
html[data-theme=light] { --pk-press: #c7d1df; --pk-pressS: #8e9db4; --pk-glyph: #b8c4d6; }
#park { display: block; width: 100%; height: auto; min-width: 900px; }
#park text { font-family: inherit; }
.pk-box { fill: var(--panel2); stroke: var(--stroke); }
.pk-box.warn { stroke: var(--warn); stroke-width: 2; }
.pk-hall { fill: none; stroke: var(--stroke); stroke-width: 1.4; stroke-dasharray: 7 5; }
.pk-cell { fill: var(--panel); stroke: var(--stroke); }
.pk-press { fill: var(--pk-press); stroke: var(--pk-pressS); stroke-width: 1.2; }
.pk-col { fill: var(--pk-pressS); }
.pk-ex50 { fill: var(--dstd); } .pk-ex35 { fill: var(--dspec); }
.pk-op { fill: #e8b400; } .pk-trim { fill: var(--mute); } .pk-disc { fill: #111; stroke: var(--mute); }
.pk-glyph { fill: var(--pk-glyph); stroke: var(--pk-pressS); }
.pk-feed { stroke: var(--mute); stroke-width: 1; fill: none; stroke-dasharray: 2 3; }
.pk-t { fill: var(--fg); font-size: 12px; font-weight: 600; }
.pk-s { fill: var(--mute); font-size: 10.5px; }
.pk-v { fill: var(--fg); font-size: 15px; font-weight: 700; }
.pk-n { fill: var(--fg); font-size: 19px; font-weight: 700; }
.pk-lane { fill: var(--mute); font-size: 11px; letter-spacing: 0.12em; font-weight: 600; }
.pk-arrow { stroke: var(--mute); stroke-width: 1.6; fill: none; }
.pk-head { fill: var(--mute); }
.pk-util { fill: var(--line); } .pk-utilv { fill: var(--accent); } .pk-utilv.hi { fill: var(--warn); }
.pk-pipe { fill: none; stroke-width: 3; stroke-dasharray: 8 5; opacity: 0.85; }
.pk-steam { stroke: #e2462a; } .pk-cool { stroke: #2f7de1; } .pk-hyd { stroke: #d48600; }
.pk-air { stroke: #8a97b0; } .pk-pow { stroke: #c9a800; }
tr.tot td { font-weight: 600; border-top: 1px solid var(--stroke); }
td.neg { color: var(--bad); }
#ecoBar div { min-width: 0; }
.themebtn {
position: absolute; top: 24px; right: 20px; background: var(--panel); color: var(--fg);
border: 1px solid var(--stroke); border-radius: 5px; padding: 6px 12px; font: inherit; font-size: 14px; cursor: pointer;
}
.themebtn:hover { border-color: var(--accent); }
header { position: relative; }
@media (max-width: 640px) { header h1 { font-size: 22px; padding-right: 128px; } }
* { box-sizing: border-box; }
body {
margin: 0; background: var(--bg); color: var(--fg);
font: 15px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
a { color: var(--accent); }
header, .wrap { max-width: 1320px; margin: 0 auto; padding: 0 20px; }
header { padding-top: 28px; }
h1 { margin: 0; font-size: 26px; letter-spacing: 0.02em; }
.tag {
display: inline-block; margin-top: 6px; padding: 2px 8px;
border: 1px solid var(--stroke); border-radius: 4px; color: var(--mute); font-size: 13px;
}
.lead { color: var(--mute); max-width: 920px; margin: 14px 0 22px; }
.grid { display: grid; grid-template-columns: 370px 1fr; gap: 18px; align-items: start; }
@media (max-width: 960px) { .grid { grid-template-columns: 1fr; } }
.card {
background: var(--panel); border: 1px solid var(--stroke); border-radius: 8px;
padding: 16px 18px; margin-bottom: 18px;
}
h2 {
font-size: 12px; text-transform: uppercase; letter-spacing: 0.08em;
color: var(--mute); margin: 0 0 12px;
}
.row { display: flex; justify-content: space-between; align-items: center; gap: 10px; margin: 9px 0; }
.row > span { flex: 1; }
.row small, .hint { color: var(--mute); font-size: 12.5px; }
.hint { margin: 6px 0 0; }
input[type=number] {
width: 118px; background: var(--input); color: var(--fg); border: 1px solid var(--stroke);
border-radius: 4px; padding: 5px 7px; font: inherit; text-align: right;
font-variant-numeric: tabular-nums;
}
input[type=number]:focus { outline: none; border-color: var(--accent); }
input[type=range] { width: 100%; accent-color: var(--accent); }
.seg { display: flex; border: 1px solid var(--stroke); border-radius: 6px; overflow: hidden; margin: 6px 0 10px; }
.seg button {
flex: 1; background: transparent; color: var(--mute); border: 0; padding: 7px 8px;
font: inherit; font-size: 14px; cursor: pointer;
}
.seg button + button { border-left: 1px solid var(--stroke); }
.seg button.on { background: var(--panel2); color: var(--accent); }
.pair { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.pair .row { margin: 4px 0; }
.pair input[type=number] { width: 70px; }
.kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 10px; }
.kpi { background: var(--panel2); border: 1px solid var(--stroke); border-radius: 6px; padding: 10px 12px; }
.kpi b { display: block; font-size: 24px; font-variant-numeric: tabular-nums; }
.kpi span { color: var(--mute); font-size: 13px; }
.kpi.d b { color: var(--dstd); } .kpi.c b { color: var(--classic); }
table { width: 100%; border-collapse: collapse; font-size: 14px; }
th, td { padding: 6px 8px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; }
th { color: var(--mute); font-weight: 500; font-size: 12.5px; }
td.n, th.n { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
tr.grp td {
color: var(--accent); font-size: 12px; text-transform: uppercase; letter-spacing: 0.07em;
padding-top: 14px; border-bottom-color: var(--stroke);
}
td.src { color: var(--mute); font-size: 12.5px; }
.flag { border-left: 3px solid var(--warn); background: var(--flagw); padding: 7px 10px; margin: 8px 0 0; font-size: 14px; }
.flag.ok { border-color: var(--accent); background: var(--flago); }
.flag.bad { border-color: var(--bad); background: var(--flagb); }
.bar { display: flex; height: 24px; border-radius: 4px; overflow: hidden; margin: 14px 0 8px; background: var(--panel2); }
.bar div { height: 100%; }
.legend { display: flex; flex-wrap: wrap; gap: 6px 18px; font-size: 13.5px; color: var(--mute); }
.legend i { display: inline-block; width: 10px; height: 10px; border-radius: 2px; margin-right: 6px; }
#ladT tbody tr { cursor: pointer; }
#ladT tbody tr:hover td { background: var(--panel2); }
#ladT tbody tr.cur td { background: var(--cur); }
.badge {
display: inline-block; font-size: 11px; padding: 0 6px; border-radius: 3px;
border: 1px solid var(--stroke); color: var(--mute); white-space: nowrap;
}
.badge.vendor { color: var(--bv); border-color: var(--bvb); }
.badge.doc { color: var(--bd); border-color: var(--bdb); }
.badge.est { color: var(--be); border-color: var(--beb); }
.badge.user { color: var(--bu); border-color: var(--bub); }
#asT input[type=number] { width: 92px; }
#asT tr.chg input { border-color: var(--warn); }
.btns { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 12px; }
.btns button {
background: var(--panel2); color: var(--fg); border: 1px solid var(--stroke); border-radius: 5px;
padding: 7px 12px; font: inherit; font-size: 14px; cursor: pointer;
}
.btns button:hover { border-color: var(--accent); }
.note { color: var(--mute); font-size: 13.5px; }
.note p { margin: 6px 0; }
footer { color: var(--mute); font-size: 13px; padding: 10px 20px 40px; max-width: 1320px; margin: 0 auto; }
.presets { display: flex; flex-wrap: wrap; gap: 8px; margin: -8px 0 22px; align-items: center; }
.presets > span { color: var(--mute); font-size: 13px; margin-right: 2px; }
.presets button {
background: var(--panel); color: var(--fg); border: 1px solid var(--stroke); border-radius: 5px;
padding: 6px 11px; font: inherit; font-size: 13.5px; cursor: pointer;
}
.presets button:hover { border-color: var(--accent); }
.presets button.on { border-color: var(--accent); color: var(--accent); background: var(--panel2); }
.cfg { border-top: 1px solid var(--line); padding: 9px 0 3px; }
.cfg:first-of-type { border-top: 0; padding-top: 0; }
.cfg .ct { font-size: 14px; font-weight: 600; }
.cfg .ct small { display: block; font-weight: 400; color: var(--mute); font-size: 12px; }
.cfg .seg { margin: 6px 0 4px; }
.cfg .seg button { padding: 6px 6px; font-size: 13.5px; line-height: 1.25; }
.seg button small.dl { display: block; font-size: 11.5px; color: var(--mute); font-variant-numeric: tabular-nums; }
.seg button.on small.dl { color: var(--accent); }
.seg button:disabled { opacity: 0.42; cursor: not-allowed; pointer-events: none; }
.cfg.off .ct { opacity: 0.55; }
.cfg .hint { margin: 0 0 4px; font-size: 12px; }
.cfg .row { margin: 4px 0 6px; }
.cfg .row.off { opacity: 0.5; }
input[type=number]:disabled { opacity: 0.55; cursor: not-allowed; }
.bud { display: grid; grid-template-columns: 74px 1fr; gap: 14px 10px; align-items: center; margin: 22px 0 6px; }
.bud .bl { font-size: 13px; color: var(--mute); line-height: 1.2; }
.bud .bl b { display: block; color: var(--fg); font-size: 14px; }
.btrack { position: relative; height: 22px; background: var(--panel2); border-radius: 4px; }
.bfill { position: absolute; left: 0; top: 0; bottom: 0; border-radius: 4px 0 0 4px; background: var(--dstd); }
.bfill.res { background: repeating-linear-gradient(45deg, var(--dspec) 0 5px, transparent 5px 9px); border-radius: 0; opacity: 0.85; }
.bmark { position: absolute; top: -5px; bottom: -5px; width: 2px; background: var(--fg); opacity: 0.75; }
.bmark.lo { background: var(--warn); opacity: 1; }
.bmark i { position: absolute; top: -17px; width: 44px; font-style: normal; font-size: 11px; color: var(--mute); }
.bmark.lo i { right: 4px; text-align: right; }
.bmark:not(.lo) i { left: 4px; text-align: left; }
.bgap { grid-column: 2; margin-top: -10px; font-size: 12.5px; color: var(--mute); }
.bgap .over { color: var(--bad); } .bgap .fit { color: var(--accent); }
#cashSvg { display: block; width: 100%; height: auto; margin: 6px 0 4px; }
.cv-pos { fill: var(--accent); opacity: 0.8; } .cv-neg { fill: var(--bad); opacity: 0.8; }
.cv-line { fill: none; stroke: var(--dstd); stroke-width: 2.2; } .cv-dot { fill: var(--dstd); }
.cv-axis { stroke: var(--stroke); stroke-width: 1; } .cv-zero { stroke: var(--mute); stroke-width: 1; stroke-dasharray: 3 3; }
.cv-t { fill: var(--mute); font-size: 11px; }
.inl { display: flex; flex-wrap: wrap; gap: 6px 14px; align-items: center; margin: 4px 0 8px; font-size: 13.5px; }
.inl label { display: flex; align-items: center; gap: 6px; }
.inl input[type=number] { width: 64px; }
#ivT td.n, #cashT td.n, #cashT td:first-child { white-space: nowrap; }
.pk-off { opacity: 0.32; }
.pk-tag { fill: var(--warn); font-size: 10.5px; font-weight: 600; }
[hidden] { display: none !important; }
.grid > div { min-width: 0; }
.tscroll { overflow-x: auto; }
.tscroll table { min-width: 560px; }
#ladT { min-width: 1140px; }
#cashSvg { min-width: 480px; }
</style>
<style id="iso-plant-css">
/* Isometric plant view (iso-plant.css): all classes iso-, colours as --iso-* variables */
/* iso-plant.css: CNX Vinyl isometric plant view. All colours are CSS
variables: dark (dusk, lit windows) by default, html[data-theme=light] is day.
Class names are prefixed iso-. Faces: iso-t top, iso-l left, iso-c diagonal,
iso-r right; each material class sets --b and the faces mix it with shade. */
.iso-wrap {
--iso-sky1: #0b1428; --iso-sky2: #251f42; --iso-star: rgba(255, 255, 255, .55);
--iso-shade: #04060c; --iso-kl: 78%; --iso-kc: 70%; --iso-kr: 58%;
--iso-edge: rgba(4, 7, 14, .72); --iso-edgew: .32;
--iso-grass: #1d3a2b; --iso-dirt: #3d3026; --iso-grid: rgba(190, 230, 210, .06);
--iso-asph: #252a34; --iso-conc: #3c4250; --iso-pad: #454c5a; --iso-path: #4b5161;
--iso-dash: #cdbb68; --iso-kerb: #59606e; --iso-bayline: #8d96a8; --iso-hazard: #b58a12;
--iso-plinth: #5a606c; --iso-wall: #7a8496; --iso-wall2: #737d8f; --iso-roof: #8792a6; --iso-roof2: #808a9c; --iso-roof3: #8f8c9a; --iso-roofin: #777484;
--iso-office: #8a8796; --iso-yardb: #6f798b; --iso-cut: #222733; --iso-firewall: #8c4a48;
--iso-floor: #4a5263; --iso-part: #161b27; --iso-mark: #d9ad2e; --iso-trench: #2b303b;
--iso-glass: #f2c56e; --iso-glasso: .72; --iso-glassw: #9fd0ff; --iso-glasswo: .16;
--iso-win: #283248; --iso-winlit: #ffcd6a; --iso-door: #353d4c; --iso-dock: #8a93a1; --iso-open: #12161e;
--iso-pv: #17275a; --iso-pvl: #4d68ad; --iso-glint: #ffffff;
--iso-led-r: #ff5a3c; --iso-led-g: #46f59a; --iso-led-o: #ffae34; --iso-fire: #ff8a2a; --iso-lamp: #ffd98a; --iso-lampo: .5;
--iso-steam: #dfe7f1; --iso-smoke: #7b7f88;
--iso-steel: #8b95a6; --iso-press: #2f3a4f; --iso-press2: #3e4b64; --iso-ex50: #3a7bd2; --iso-ex35: #9a68d4;
--iso-hopper: #bfc6d1; --iso-classic: #2c9a60; --iso-vinyl: #121419; --iso-hivis: #f0b400; --iso-head: #e9c9a3;
--iso-galv: #4d97a3; --iso-galv2: #5aa9b5; --iso-bath: #3fd0a8; --iso-sibert: #b6c1ce; --iso-bench: #8b7b63;
--iso-rack: #c45c2b; --iso-carton: #9c7a4e; --iso-crane: #e3b100; --iso-col: #687386; --iso-rail: #c7a300;
--iso-boilere: #c6cbd4; --iso-boilerb: #b0412e; --iso-padc: #686e7a; --iso-padempty: #535a67;
--iso-chiller: #a7b1be; --iso-screen: #6d7685; --iso-bess: #c9cfd8; --iso-inv: #39414f; --iso-trafo: #58626f;
--iso-water: #6f8fb0; --iso-firetank: #b03a3a; --iso-stack: #99a0ac; --iso-chips: #9a7744; --iso-tank: #7f8ea3;
--iso-silok: #22252c; --iso-siloy: #d4b229; --iso-silor: #b5403a; --iso-canopy: #687182;
--iso-car1: #b8382b; --iso-car2: #2e7fd2; --iso-car3: #d5dbe2; --iso-car4: #2d3238; --iso-glassd: #1c2433;
--iso-cab: #e6ebf2; --iso-truckbox: #3a6db4; --iso-chassis: #20242c; --iso-sacks: #ddd2b8;
--iso-fence: #9aa3b1; --iso-fenceo: .22; --iso-gate: #8a94a4; --iso-boom: #e04b3a; --iso-ac: #a1a9b6;
--iso-trunk: #5a4430; --iso-leaf: #2c6640; --iso-person: #f0b400;
--iso-tagbg: rgba(10, 16, 30, .9); --iso-tagline: #3b5277; --iso-tagfg: #e4ecf7;
--iso-signbg: #5e4300; --iso-signline: #d9a300; --iso-signfg: #ffe2a2;
--iso-warnbg: #5a1f1f; --iso-warnline: #ff6b6b; --iso-warnfg: #ffd4d4;
--iso-tipbg: #0f1626; --iso-tipfg: #d6deeb; --iso-tipline: #2a3a56; --iso-tipmute: #8a97b0;
--iso-btn: #0f1626; --iso-btnfg: #d6deeb; --iso-btnline: #2a3a56; --iso-btnon: #12261c; --iso-accent: #3ddc84;
--iso-sel: #3ddc84; --iso-hov: #9fd0ff; --iso-mute: #8a97b0;
position: relative;
color: var(--iso-tipfg);
}
html[data-theme=light] .iso-wrap {
--iso-sky1: #a9d4fb; --iso-sky2: #e8f4ff; --iso-star: transparent;
--iso-shade: #263040; --iso-kl: 86%; --iso-kc: 78%; --iso-kr: 68%;
--iso-edge: rgba(36, 46, 62, .6); --iso-edgew: .3;
--iso-grass: #95c477; --iso-dirt: #8f6c4c; --iso-grid: rgba(20, 60, 20, .07);
--iso-asph: #6b717c; --iso-conc: #c3c7ce; --iso-pad: #b6bbc3; --iso-path: #d6d0c1;
--iso-dash: #ffffff; --iso-kerb: #9aa0aa; --iso-bayline: #f4f6f8; --iso-hazard: #e7b70f;
--iso-plinth: #b5bac3; --iso-wall: #e3e7ec; --iso-wall2: #dce1e7; --iso-roof: #f1f4f8; --iso-roof2: #e9edf2; --iso-roof3: #ece6da; --iso-roofin: #ddd6c8;
--iso-office: #efe8db; --iso-yardb: #d6dbe2; --iso-cut: #565d6a; --iso-firewall: #cf8a82;
--iso-floor: #d4d8df; --iso-part: #6f7889; --iso-mark: #d39d00; --iso-trench: #8d939c;
--iso-glass: #8fc0e6; --iso-glasso: .85; --iso-glassw: #5aa6e6; --iso-glasswo: .14;
--iso-win: #86b3dc; --iso-winlit: #9ac3e6; --iso-door: #858e9c; --iso-dock: #b6bdc7; --iso-open: #444a55;
--iso-pv: #22448d; --iso-pvl: #8aa8e2; --iso-glint: #ffffff;
--iso-led-r: #d63b1f; --iso-led-g: #18a35a; --iso-led-o: #d98300; --iso-fire: #f07b1c; --iso-lamp: #fff2c4; --iso-lampo: 0;
--iso-steam: #ffffff; --iso-smoke: #8d9097;
--iso-steel: #a9b2bf; --iso-press: #46546c; --iso-press2: #5a6984; --iso-ex50: #2f7de1; --iso-ex35: #9b5de5;
--iso-hopper: #dde2e8; --iso-classic: #1fae68; --iso-vinyl: #1b1e25; --iso-hivis: #f2b600; --iso-head: #e2b892;
--iso-galv: #6ab3bd; --iso-galv2: #7cc2cb; --iso-bath: #2fbf98; --iso-sibert: #ccd5df; --iso-bench: #c8b28c;
--iso-rack: #d76b32; --iso-carton: #c59b64; --iso-crane: #f2c200; --iso-col: #9aa4b3; --iso-rail: #d6b000;
--iso-boilere: #eef0f4; --iso-boilerb: #c9523b; --iso-padc: #c1c5cc; --iso-padempty: #b1b6be;
--iso-chiller: #dbe0e6; --iso-screen: #a0a9b6; --iso-bess: #f2f4f7; --iso-inv: #5b6370; --iso-trafo: #7d8794;
--iso-water: #9cb9d6; --iso-firetank: #d0453f; --iso-stack: #b9bfc8; --iso-chips: #b88b4c; --iso-tank: #a7b4c5;
--iso-silok: #2b2f37; --iso-siloy: #e9c22e; --iso-silor: #d0453f; --iso-canopy: #a8b0bd;
--iso-car1: #d0453f; --iso-car2: #2f7de1; --iso-car3: #f3f5f8; --iso-car4: #3a3f47; --iso-glassd: #3d4c62;
--iso-cab: #ffffff; --iso-truckbox: #2f6fd0; --iso-chassis: #2a2e36; --iso-sacks: #efe6cf;
--iso-fence: #7d8794; --iso-fenceo: .25; --iso-gate: #9aa3b1; --iso-boom: #e0412f; --iso-ac: #ced3da;
--iso-trunk: #6e5238; --iso-leaf: #4c9a4b; --iso-person: #f2b600;
--iso-tagbg: rgba(255, 255, 255, .93); --iso-tagline: #a9b8cc; --iso-tagfg: #1b2533;
--iso-signbg: #ffe7a6; --iso-signline: #c27c00; --iso-signfg: #5b3b00;
--iso-warnbg: #fdeaea; --iso-warnline: #c62828; --iso-warnfg: #8b1a1a;
--iso-tipbg: #ffffff; --iso-tipfg: #1b2533; --iso-tipline: #cfd8e6; --iso-tipmute: #5b6880;
--iso-btn: #ffffff; --iso-btnfg: #1b2533; --iso-btnline: #cfd8e6; --iso-btnon: #e1f3e8; --iso-accent: #0e8f4f;
--iso-sel: #0e8f4f; --iso-hov: #2f7de1; --iso-mute: #5b6880;
}
/* stage, toolbar, tooltip, note */
.iso-stage {
position: relative; width: 100%; aspect-ratio: 16 / 9; min-height: 340px; max-height: 82vh;
overflow: hidden; border-radius: 8px; border: 1px solid var(--iso-btnline);
background:
radial-gradient(1px 1px at 12% 18%, var(--iso-star) 50%, transparent 51%),
radial-gradient(1px 1px at 33% 9%, var(--iso-star) 50%, transparent 51%),
radial-gradient(1px 1px at 58% 14%, var(--iso-star) 50%, transparent 51%),
radial-gradient(1.2px 1.2px at 81% 7%, var(--iso-star) 50%, transparent 51%),
radial-gradient(1px 1px at 91% 22%, var(--iso-star) 50%, transparent 51%),
linear-gradient(180deg, var(--iso-sky1), var(--iso-sky2));
}
.iso-svg { display: block; width: 100%; height: 100%; touch-action: pan-y; cursor: grab; user-select: none; -webkit-user-select: none; outline: none; }
.iso-svg.iso-dragging { cursor: grabbing; }
.iso-svg:focus-visible { outline: 2px solid var(--iso-accent); outline-offset: -2px; }
.iso-bar {
position: absolute; top: 8px; left: 8px; right: 8px; display: flex; flex-wrap: wrap; gap: 6px; align-items: center;
pointer-events: none; font: 13px/1.2 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
.iso-bar > *, .iso-bar button { pointer-events: auto; }
.iso-bar button, .iso-compass {
background: var(--iso-btn); color: var(--iso-btnfg); border: 1px solid var(--iso-btnline); border-radius: 5px;
padding: 5px 10px; font: inherit; cursor: pointer; min-width: 32px; min-height: 30px;
}
.iso-bar button:hover { border-color: var(--iso-accent); }
.iso-bar button:focus-visible { outline: 2px solid var(--iso-accent); outline-offset: 1px; }
.iso-seg { display: inline-flex; }
.iso-seg button { border-radius: 0; }
.iso-seg button:first-child { border-radius: 5px 0 0 5px; }
.iso-seg button:last-child { border-radius: 0 5px 5px 0; }
.iso-seg button + button { border-left: 0; }
.iso-seg button[aria-pressed=true] { background: var(--iso-btnon); border-color: var(--iso-accent); color: var(--iso-btnfg); font-weight: 600; }
.iso-compass { display: inline-flex; align-items: center; gap: 2px; padding: 3px 7px; cursor: default; }
.iso-compass svg { width: 20px; height: 20px; }
.iso-compass path { fill: var(--iso-accent); }
.iso-compass b { font-size: 11px; }
.iso-tip {
position: absolute; z-index: 3; max-width: min(330px, calc(100% - 12px)); padding: 8px 10px; pointer-events: none;
background: var(--iso-tipbg); color: var(--iso-tipfg); border: 1px solid var(--iso-tipline); border-radius: 6px;
font: 13px/1.4 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; box-shadow: 0 4px 14px rgba(0, 0, 0, .28);
}
.iso-tip b { display: block; font-weight: 600; margin-bottom: 2px; }
.iso-tip span { display: block; color: var(--iso-tipmute); font-size: 12px; }
.iso-tip[hidden], .iso-wrap [hidden] { display: none !important; }
.iso-note { margin: 6px 2px 0; font: 12px/1.4 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; color: var(--iso-mute); }
/* faces */
.iso-svg .iso-t { fill: var(--b, #888); }
.iso-svg .iso-l { fill: color-mix(in srgb, var(--b, #888) var(--iso-kl), var(--iso-shade)); }
.iso-svg .iso-c { fill: color-mix(in srgb, var(--b, #888) var(--iso-kc), var(--iso-shade)); }
.iso-svg .iso-r { fill: color-mix(in srgb, var(--b, #888) var(--iso-kr), var(--iso-shade)); }
.iso-sprites path, .iso-sprites ellipse, .iso-sprites circle { stroke: var(--iso-edge); stroke-width: var(--iso-edgew); stroke-linejoin: round; }
.iso-ground path, .iso-ground ellipse { stroke: none; }
.iso-ground .iso-dirt { stroke: var(--iso-edge); stroke-width: .4; }
/* browsers without color-mix(): flat faces instead of black */
@supports not (color: color-mix(in srgb, red 50%, blue)) {
.iso-svg .iso-l, .iso-svg .iso-c, .iso-svg .iso-r { fill: var(--b, #888); }
.iso-svg .iso-r { fill-opacity: .82; }
}
/* ground */
.iso-dirt { --b: var(--iso-dirt); }
.iso-g-grass { --b: var(--iso-grass); }
.iso-g-asph { --b: var(--iso-asph); }
.iso-g-conc { --b: var(--iso-conc); }
.iso-g-pad { --b: var(--iso-pad); }
.iso-g-path { --b: var(--iso-path); }
.iso-svg .iso-grid { fill: none; stroke: var(--iso-grid); stroke-width: .5; }
.iso-svg .iso-dash { fill: none; stroke: var(--iso-dash); stroke-width: .6; stroke-dasharray: 8 8; }
.iso-svg .iso-kerb { fill: none; stroke: var(--iso-kerb); stroke-width: .6; }
.iso-svg .iso-bayline { fill: none; stroke: var(--iso-bayline); stroke-width: .45; opacity: .8; }
.iso-svg .iso-hazard { fill: var(--iso-hazard); opacity: .85; }
.iso-svg .iso-buried { fill: none; stroke: var(--iso-mark); stroke-width: .5; stroke-dasharray: 2 3; opacity: .7; }
.iso-svg .iso-pool { opacity: var(--iso-lampo); pointer-events: none; }
.iso-pool0 { stop-color: var(--iso-lamp); stop-opacity: .55; }
.iso-pool1 { stop-color: var(--iso-lamp); stop-opacity: 0; }
/* materials */
.iso-plinth { --b: var(--iso-plinth); } .iso-wall { --b: var(--iso-wall); } .iso-wall2 { --b: var(--iso-wall2); }
.iso-roof { --b: var(--iso-roof); } .iso-roof2 { --b: var(--iso-roof2); } .iso-roof3 { --b: var(--iso-roof3); }
.iso-office { --b: var(--iso-office); } .iso-yardb { --b: var(--iso-yardb); } .iso-cut { --b: var(--iso-cut); }
.iso-firewall { --b: var(--iso-firewall); } .iso-conc { --b: var(--iso-plinth); } .iso-steel { --b: var(--iso-steel); }
.iso-press { --b: var(--iso-press); } .iso-press2 { --b: var(--iso-press2); } .iso-ex50 { --b: var(--iso-ex50); } .iso-ex35 { --b: var(--iso-ex35); }
.iso-hopper { --b: var(--iso-hopper); } .iso-classic { --b: var(--iso-classic); } .iso-vinyl { --b: var(--iso-vinyl); }
.iso-hivis { --b: var(--iso-hivis); } .iso-guide { --b: var(--iso-accent); } .iso-visitor { --b: #d77a5a; } .iso-visitor2 { --b: #7a8fd8; }
.iso-galv { --b: var(--iso-galv); } .iso-galv2 { --b: var(--iso-galv2); } .iso-bath { --b: var(--iso-bath); } .iso-sibert { --b: var(--iso-sibert); }
.iso-bench { --b: var(--iso-bench); } .iso-desk { --b: var(--iso-bench); } .iso-table { --b: var(--iso-bench); } .iso-sofa { --b: #6b5a8e; }
.iso-rackf { --b: var(--iso-rack); } .iso-shelf { --b: var(--iso-steel); } .iso-archive { --b: #7d8aa0; }
.iso-carton { --b: var(--iso-carton); } .iso-crane { --b: var(--iso-crane); } .iso-hoist { --b: #b98f00; } .iso-hook { --b: #39404c; }
.iso-col { --b: var(--iso-col); } .iso-rail { --b: var(--iso-rail); }
.iso-boilere { --b: var(--iso-boilere); } .iso-boilerb { --b: var(--iso-boilerb); } .iso-panel { --b: #3a4352; }
.iso-pad { --b: var(--iso-padc); } .iso-padempty { --b: var(--iso-padempty); } .iso-chiller { --b: var(--iso-chiller); }
.iso-screen { --b: var(--iso-screen); } .iso-bess { --b: var(--iso-bess); } .iso-inv { --b: var(--iso-inv); } .iso-trafo { --b: var(--iso-trafo); }
.iso-water { --b: var(--iso-water); } .iso-firetank { --b: var(--iso-firetank); } .iso-stack { --b: var(--iso-stack); } .iso-stackband { --b: #b8413a; }
.iso-chips { --b: var(--iso-chips); } .iso-tank { --b: var(--iso-tank); } .iso-evap { --b: #9aa8bb; } .iso-softener { --b: #4f7fc0; }
.iso-silok { --b: var(--iso-silok); } .iso-siloy { --b: var(--iso-siloy); } .iso-silor { --b: var(--iso-silor); }
.iso-frame { --b: var(--iso-steel); } .iso-bigbag { --b: #e8e2d0; } .iso-canopy { --b: var(--iso-canopy); }
.iso-car1 { --b: var(--iso-car1); } .iso-car2 { --b: var(--iso-car2); } .iso-car3 { --b: var(--iso-car3); } .iso-car4 { --b: var(--iso-car4); }
.iso-glassd { --b: var(--iso-glassd); } .iso-cab { --b: var(--iso-cab); } .iso-truckbox { --b: var(--iso-truckbox); }
.iso-chassis { --b: var(--iso-chassis); } .iso-sacks { --b: var(--iso-sacks); }
.iso-gate { --b: var(--iso-gate); } .iso-boom { --b: var(--iso-boom); } .iso-ac { --b: var(--iso-ac); }
.iso-lampost { --b: #4a5160; } .iso-roofcap { --b: #6d7889; } .iso-roofcap2 { --b: var(--iso-roof2); }
.iso-locker { --b: #6d7f99; } .iso-printer { --b: #d5d9df; } .iso-mill { --b: #5d7aa0; } .iso-granul { --b: #4c8a8f; }
.iso-instr { --b: #dfe3e8; } .iso-instr2 { --b: #4b6a9c; } .iso-optic { --b: #d9dee5; } .iso-dome { --b: #2c3442; } .iso-bin { --b: #b8413a; }
.iso-speaker { --b: #2b2f37; } .iso-shrink { --b: #9aa4b2; } .iso-cass { --b: #333a46; } .iso-cdtower { --b: #c7ccd4; }
.iso-forklift { --b: #e3a400; } .iso-pump { --b: #3f78c0; } .iso-motor { --b: #59606b; } .iso-accu { --b: #c9473a; }
.iso-comp { --b: #4f8d58; } .iso-oil { --b: #b88a2a; } .iso-belt { --b: #2b313b; } .iso-hx { --b: #8aa0b8; }
.iso-lathe { --b: #3c4658; } .iso-stair { --b: #9aa0aa; } .iso-lift { --b: #737b88; } .iso-rail2 { --b: var(--iso-steel); }
.iso-server { --b: #27303f; } .iso-shower { --b: #5f9fd6; } .iso-wc { --b: #8e9aa9; } .iso-sanit { --b: #eef2f5; } .iso-cubicle { --b: #a7b9c9; } .iso-sign { --b: var(--iso-signline); } .iso-fanbox { --b: #6b7585; }
.iso-bushing { --b: #c9b8a4; } .iso-pipes { --b: #c9473a; } .iso-pipec { --b: #3f78c0; } .iso-pipec2 { --b: #6aa8ff; }
.iso-svg .iso-headc { fill: var(--iso-head); }
.iso-svg .iso-fence, .iso-svg .iso-fence.iso-t { fill-opacity: var(--iso-fenceo); --b: var(--iso-fence); }
.iso-fence { --b: var(--iso-fence); }
.iso-svg .iso-screen.iso-l, .iso-svg .iso-screen.iso-r, .iso-svg .iso-screen.iso-c { fill-opacity: .55; }
.iso-svg .iso-glassw { --b: var(--iso-glassw); fill-opacity: var(--iso-glasswo); }
.iso-svg path.iso-glassw.iso-t { fill-opacity: .5; }
/* openings, panels, details */
.iso-svg .iso-win { fill: var(--iso-win); stroke: none; }
.iso-svg .iso-win.iso-lit { fill: var(--iso-winlit); }
.iso-svg .iso-glass { fill: var(--iso-glass); fill-opacity: var(--iso-glasso); stroke: none; }
.iso-svg .iso-door { fill: var(--iso-door); stroke: none; }
.iso-svg .iso-door.iso-on { fill: color-mix(in srgb, var(--iso-door) 70%, var(--iso-winlit)); }
.iso-svg .iso-door2 { fill: var(--iso-dock); stroke: var(--iso-edge); stroke-width: .3; }
.iso-svg .iso-dock { fill: var(--iso-dock); stroke: var(--iso-hazard); stroke-width: .5; }
.iso-svg .iso-open { fill: var(--iso-open); stroke: none; }
.iso-svg .iso-louvre, .iso-svg .iso-fins, .iso-svg .iso-grille { fill: var(--iso-open); fill-opacity: .55; stroke: none; }
.iso-svg .iso-slats { fill: var(--iso-open); fill-opacity: .35; stroke: none; }
.iso-svg .iso-bars { fill: var(--iso-open); stroke: none; }
.iso-svg .iso-bessdoor { fill: none; stroke: var(--iso-edge); stroke-width: .35; }
.iso-svg .iso-chipsin { fill: var(--iso-chips); stroke: none; }
.iso-svg .iso-fire { fill: var(--iso-fire); stroke: none; }
.iso-svg .iso-boxes { fill: #b79b6a; fill-opacity: .75; stroke: none; }
.iso-svg .iso-pv { fill: var(--iso-pv); stroke: var(--iso-pvl); stroke-width: .28; }
.iso-svg .iso-glint { fill: var(--iso-glint); stroke: none; opacity: 0; pointer-events: none; }
.iso-svg .iso-roofin { fill: var(--iso-roofin); stroke: none; }
.iso-svg .iso-seam { fill: none; stroke: var(--iso-edge); stroke-width: .3; opacity: .55; }
.iso-svg .iso-reserve { fill: var(--iso-mark); fill-opacity: .05; stroke: var(--iso-mark); stroke-width: .6; stroke-dasharray: 4 3; opacity: .7; }
.iso-svg .iso-ventc { fill: #1d222b; }
.iso-svg .iso-tankring { fill: none; stroke: var(--iso-edge); stroke-width: .4; }
.iso-svg .iso-fanring { fill: #252b35; }
.iso-svg .iso-fan path { fill: #a9b3c1; stroke: none; }
.iso-svg .iso-sink { fill: #263241; }
.iso-svg .iso-cathode { fill: #7f8b9c; }
.iso-svg .iso-vinylc { fill: #101217; }
.iso-svg .iso-platter { fill: #9aa3b1; }
.iso-svg .iso-lacquer { fill: #1a1d24; }
.iso-svg .iso-lab1 { fill: #ffcf4a; } .iso-svg .iso-lab2 { fill: #ff6b6b; } .iso-svg .iso-lab3 { fill: #6aa8ff; } .iso-svg .iso-lab4 { fill: #f2f2f2; }
.iso-svg .iso-screenm { --b: #2a3343; }
.iso-pal-k { --b: #1f2228; } .iso-pal-w { --b: #e6eaef; } .iso-pal-r { --b: #d0453f; } .iso-pal-y { --b: #e8c22e; }
.iso-pal-b { --b: #2f7de1; } .iso-pal-g { --b: #2fae68; } .iso-pal-p { --b: #9b5de5; } .iso-pal-o { --b: #f08a2a; } .iso-pal-bx { --b: var(--iso-carton); }
.iso-svg .iso-steps { fill: none; stroke: var(--iso-edge); stroke-width: .35; }
.iso-svg .iso-later { fill: none; stroke: var(--iso-mark); stroke-width: .4; stroke-dasharray: 1.5 1.5; }
.iso-svg .iso-trunk { fill: var(--iso-trunk); stroke: none; }
.iso-leaf { --b: var(--iso-leaf); }
.iso-svg .iso-rope { fill: none; stroke: #2a2f38; stroke-width: .5; }
.iso-svg .iso-lamp { fill: var(--iso-lamp); stroke: none; }
html[data-theme=light] .iso-svg .iso-lamp { fill: #c9ced6; }
.iso-svg .iso-led { stroke: none; }
.iso-svg .iso-ledr { fill: var(--iso-led-r); } .iso-svg .iso-ledg { fill: var(--iso-led-g); } .iso-svg .iso-ledo { fill: var(--iso-led-o); }
/* floors and markings (inside view) */
.iso-z-floor, .iso-z-press, .iso-z-aisle, .iso-z-yard { --b: var(--iso-floor); }
.iso-z-staff { --b: color-mix(in srgb, var(--iso-floor) 82%, #b48ae0); }
.iso-z-shop, .iso-z-shop2 { --b: color-mix(in srgb, var(--iso-floor) 82%, #e0c26a); }
.iso-z-galv { --b: color-mix(in srgb, var(--iso-floor) 76%, #4fc3d0); }
.iso-z-lab { --b: color-mix(in srgb, var(--iso-floor) 80%, #a77ad8); }
.iso-z-prep { --b: color-mix(in srgb, var(--iso-floor) 80%, #d8c76a); }
.iso-z-colour { --b: color-mix(in srgb, var(--iso-floor) 82%, #e07a4a); }
.iso-z-plant { --b: color-mix(in srgb, var(--iso-floor) 78%, #5fbf72); }
.iso-z-trim, .iso-z-qc { --b: color-mix(in srgb, var(--iso-floor) 80%, #6aa8ff); }
.iso-z-pack { --b: color-mix(in srgb, var(--iso-floor) 84%, #c8a070); }
.iso-z-wh, .iso-z-disp { --b: color-mix(in srgb, var(--iso-floor) 84%, #8fa6c8); }
.iso-z-corr { --b: color-mix(in srgb, var(--iso-floor) 74%, #9fd0ff); }
.iso-z-lobby, .iso-z-canteen { --b: color-mix(in srgb, var(--iso-office) 70%, var(--iso-floor)); }
.iso-z-cut, .iso-z-listen { --b: color-mix(in srgb, var(--iso-floor) 72%, #6b4a8e); }
.iso-z-arch { --b: color-mix(in srgb, var(--iso-floor) 76%, #6aa8ff); }
.iso-z-core { --b: color-mix(in srgb, var(--iso-floor) 88%, #c9ced6); }
.iso-z-hall { --b: color-mix(in srgb, var(--iso-office) 55%, var(--iso-floor)); }
.iso-z-staff2 { --b: color-mix(in srgb, var(--iso-floor) 80%, #7fa8d8); }
.iso-z-kitchen { --b: color-mix(in srgb, var(--iso-floor) 76%, #c89a6a); }
.iso-z-wc { --b: color-mix(in srgb, var(--iso-floor) 80%, #8fbfb4); }
.iso-z-offices { --b: color-mix(in srgb, var(--iso-floor) 76%, #8fb0e0); }
.iso-z-meet, .iso-z-meet2 { --b: color-mix(in srgb, var(--iso-floor) 74%, #7fcf98); }
.iso-z-it { --b: color-mix(in srgb, var(--iso-floor) 70%, #4f7fc0); }
.iso-z-pantry { --b: color-mix(in srgb, var(--iso-floor) 74%, #e0b070); }
.iso-z-flex { --b: color-mix(in srgb, var(--iso-floor) 80%, #b8a8e0); }
.iso-z-print { --b: color-mix(in srgb, var(--iso-floor) 74%, #d08ad0); }
.iso-z-stock { --b: color-mix(in srgb, var(--iso-floor) 76%, #e0cc90); }
.iso-z-packmat { --b: color-mix(in srgb, var(--iso-floor) 78%, #90d0a0); }
.iso-svg .iso-part { fill: none; stroke: var(--iso-part); stroke-width: .9; stroke-linecap: square; }
.iso-svg .iso-qcbelt { fill: none; stroke: #2b313b; stroke-width: .8; stroke-linecap: round; }
.iso-svg .iso-mark { fill: none; stroke: var(--iso-mark); stroke-width: .7; stroke-dasharray: 3 2; }
.iso-svg .iso-trench { fill: var(--iso-trench); stroke: none; }
.iso-svg .iso-trenchl { fill: none; stroke: var(--iso-trench); stroke-width: 2.2; }
.iso-svg .iso-grate { fill: none; stroke: var(--iso-part); stroke-width: .35; }
.iso-svg .iso-slot { fill: none; stroke: var(--iso-mark); stroke-width: .6; }
.iso-svg .iso-slot.iso-empty { stroke-dasharray: 3 2.4; fill: var(--iso-mark); fill-opacity: .06; }
.iso-svg .iso-cell { fill: none; stroke: var(--iso-hivis); stroke-width: .45; stroke-dasharray: 1.4 1.4; opacity: .8; }
.iso-svg .iso-bay { fill: var(--iso-mark); fill-opacity: .1; stroke: var(--iso-mark); stroke-width: .5; stroke-dasharray: 2 2; }
.iso-svg .iso-void { fill: var(--iso-open); stroke: none; }
.iso-svg .iso-lining { fill: none; stroke: #8a6fb0; stroke-width: 1.4; opacity: .75; }
/* 1F level: single-storey buildings stay as ghosted ground-floor context, standing still */
.iso-svg .iso-dim { opacity: .4; }
html[data-theme=light] .iso-svg .iso-dim { opacity: .5; }
.iso-svg .iso-dim .iso-a { animation: none !important; }
/* tags (counter-scaled by --iso-k so they keep their screen size) */
.iso-tag path { fill: var(--iso-tagbg); stroke: var(--iso-tagline); stroke-width: 1; }
.iso-tag text { fill: var(--iso-tagfg); font: 600 10.5px/1 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; text-anchor: middle; }
.iso-signtag path { fill: var(--iso-signbg); stroke: var(--iso-signline); }
.iso-signtag text { fill: var(--iso-signfg); }
.iso-warntag path { fill: var(--iso-warnbg); stroke: var(--iso-warnline); }
.iso-warntag text { fill: var(--iso-warnfg); }
.iso-tag { cursor: default; }
.iso-svg .iso-hide { display: none; }
.iso-svg .iso-ghost { opacity: .38; }
/* hover and selection */
.iso-svg .iso-hov > path, .iso-svg .iso-hov > ellipse, .iso-svg path.iso-hov { stroke: var(--iso-hov); stroke-width: .8; }
.iso-svg .iso-sel > path, .iso-svg .iso-sel > ellipse, .iso-svg .iso-sel > g > path, .iso-svg path.iso-sel { stroke: var(--iso-sel); stroke-width: 1.1; }
/* animation: CSS only; paused off-screen (iso-off), when stopped (iso-still) and for reduced motion */
.iso-svg .iso-fan { transform-box: fill-box; transform-origin: center; animation: iso-spin 1.1s linear infinite; }
.iso-svg .iso-puff, .iso-svg .iso-puff2, .iso-svg .iso-smoke { transform-box: fill-box; transform-origin: center; animation: iso-puff 3.2s ease-out infinite; opacity: 0; pointer-events: none; stroke: none; }
.iso-svg .iso-puff, .iso-svg .iso-puff2 { fill: var(--iso-steam); }
.iso-svg .iso-smoke { fill: var(--iso-smoke); }
.iso-svg .iso-glint { animation: iso-glint 7s ease-in-out infinite; }
.iso-svg .iso-glint { display: none; }
html[data-theme=light] .iso-svg .iso-glint { display: inline; }
.iso-svg .iso-disc { animation: iso-move 16s linear infinite; }
.iso-svg .iso-walk { animation: iso-shuttle 70s ease-in-out infinite; }
.iso-svg .iso-crane { animation: iso-shuttle 48s ease-in-out infinite; }
.iso-svg .iso-tin-r { animation: iso-tin-r 40s linear infinite; }
.iso-svg .iso-tin-g { animation: iso-tin-g 40s linear infinite; }
.iso-svg .iso-tin-d { animation: iso-tin-d 40s linear infinite; }
.iso-svg .iso-tout-g { animation: iso-tout-g 40s linear infinite; }
.iso-svg .iso-tout-r { animation: iso-tout-r 40s linear infinite; }
.iso-svg .iso-ledr, .iso-svg .iso-ledo { animation: iso-blink 2.4s ease-in-out infinite; }
.iso-svg .iso-park { display: none; }
.iso-off .iso-a, .iso-still .iso-a { animation-play-state: paused !important; }
.iso-still .iso-park { display: inline; }
@keyframes iso-spin { to { transform: rotate(360deg); } }
@keyframes iso-puff {
0% { transform: translate(0, 0) scale(.35); opacity: 0; }
12% { opacity: .75; }
100% { transform: translate(5px, -28px) scale(2); opacity: 0; }
}
@keyframes iso-glint { 0%, 84%, 100% { opacity: 0; } 91% { opacity: .9; } }
@keyframes iso-blink { 0%, 100% { opacity: 1; } 50% { opacity: .45; } }
@keyframes iso-move { from { transform: translate(0, 0); } to { transform: translate(var(--dx), var(--dy)); } }
@keyframes iso-shuttle {
0%, 8% { transform: translate(0, 0); }
46%, 56% { transform: translate(var(--dx), var(--dy)); }
94%, 100% { transform: translate(0, 0); }
}
@keyframes iso-tin-r {
0% { transform: translate(0, 0); opacity: 0; }
2% { opacity: 1; }
22% { transform: translate(var(--dx), var(--dy)); opacity: 1; }
22.2%, 100% { transform: translate(var(--dx), var(--dy)); opacity: 0; }
}
@keyframes iso-tin-g {
0%, 22% { transform: translate(0, 0); opacity: 0; }
22.2% { transform: translate(0, 0); opacity: 1; }
36% { transform: translate(var(--dx), var(--dy)); opacity: 1; }
36.2%, 100% { transform: translate(var(--dx), var(--dy)); opacity: 0; }
}
@keyframes iso-tin-d {
0%, 36% { opacity: 0; }
36.2%, 90% { opacity: 1; }
95%, 100% { opacity: 0; }
}
@keyframes iso-tout-g {
0% { transform: translate(0, 0); opacity: 0; }
4%, 45% { transform: translate(0, 0); opacity: 1; }
62% { transform: translate(var(--dx), var(--dy)); opacity: 1; }
62.2%, 100% { transform: translate(var(--dx), var(--dy)); opacity: 0; }
}
@keyframes iso-tout-r {
0%, 62% { transform: translate(0, 0); opacity: 0; }
62.2% { transform: translate(0, 0); opacity: 1; }
86% { transform: translate(var(--dx), var(--dy)); opacity: 1; }
88%, 100% { transform: translate(var(--dx), var(--dy)); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.iso-svg .iso-a { animation: none !important; }
.iso-svg .iso-puff, .iso-svg .iso-puff2, .iso-svg .iso-smoke, .iso-svg .iso-disc, .iso-svg .iso-mv, .iso-svg .iso-glint { display: none; }
.iso-svg .iso-park { display: inline; }
}
@media (max-width: 640px) {
.iso-stage { aspect-ratio: 1 / 1; min-height: 320px; }
.iso-bar { gap: 3px; top: 6px; left: 6px; right: 6px; }
.iso-bar button { padding: 4px 6px; min-width: 26px; min-height: 30px; }
.iso-compass { padding: 3px; min-height: 30px; }
.iso-compass b { display: none; }
}
/* the level selector, rotation, zoom and fit stay on one line down to ~360 px; the compass gives way first */
@media (max-width: 370px) {
.iso-compass { display: none; }
}
</style>
</head>
<body>
<header>
<button class="themebtn" id="themeBtn" type="button">Light mode</button>
<h1>Plant and business plan calculator</h1>
<span class="tag">planning estimates · not a quote</span>
<p class="lead">
Set the records you want a year and the share of splatter and other
specials, then configure the plant: galvanics in-house or bought stampers,
cutting in-house or outsourced, metrology, cassette and CD-R, solar, steam
fuel, utility sourcing, crane, visitor corridor and the ramp-up reserve. The
calculator sizes the press fleet from Newbilt Duplex systems (two HRP 12
presses, an EX 50 and an EX 35 colour extruder each) and Newbilt Classic
automatic presses, starting from the launch plant: one Duplex system and one
Classic. It builds the investment from the blocks in total-investment.md,
checks it against 180M THB and 180M plus 25 % (Plan A ~180M, Plan B ~215M), runs the P&amp;L and a
ten-year cash sketch, and lists what the plant needs: people, power, central
hydraulics, steam, cooling, compressed air, floor, compound and stampers.
Vendor figures are marked as such; the rest are planning figures and
estimates from the CNX Vinyl documents. Every figure can be changed under
Assumptions.
</p>
<div class="presets" id="presets">
<span>Presets:</span>
<button type="button" data-p="launch">Launch plant (decided)</button>
<button type="button" data-p="planA">Plan A (~180M)</button>
<button type="button" data-p="planB">Plan B (~215M, all in)</button>
<button type="button" data-p="lean">Lean: bought stampers, outsourced cutting</button>
<button type="button" data-p="full">Full hall (5 D + 4 C)</button>
</div>
</header>
<div class="wrap">
<section class="card" id="isoCard">
<h2>The plant at a glance</h2>
<p class="note" style="margin-top:0">The current plant configuration (preset or the switches below) as a
tile-game view of the site: presses,
stamper line, cutting room, utility yard with its boiler and chiller modules, solar and battery.
Switch between the ground floor (GF), the upper floors of the office and the finishing and print
block (1F) and the roofs, rotate, zoom and tap anything for its figures. Schematic,
not to scale where the vendors publish no footprint.</p>
<div id="isoPlant"></div>
</section>
</div>
<div class="wrap grid">
<div>
<section class="card">
<h2>What you want</h2>
<div class="seg" id="mode">
<button data-v="target">From a target output</button>
<button data-v="fleet">From a fleet</button>
</div>
<div id="targetBox">
<label class="row"><span>Records per year</span><input type="number" id="target" min="0" step="50000"></label>
<input type="range" id="targetR" min="0" max="10000000" step="50000" aria-label="Records per year">
<div class="row" style="margin-top:12px"><span>Grow the fleet with</span></div>
<div class="seg" id="grow">
<button data-v="classic">Classics</button>
<button data-v="duplex">Duplex systems</button>
</div>
<div class="hint" style="margin-bottom:6px">Start fleet, never fewer than:</div>
<div class="pair">
<label class="row"><span>Duplex</span><input type="number" id="dMin" min="0" step="1"></label>
<label class="row"><span>Classic</span><input type="number" id="cMin" min="0" step="1"></label>
</div>
</div>
<div id="fleetBox" hidden>
<div class="pair">
<label class="row"><span>Duplex systems</span><input type="number" id="dFleet" min="0" step="1"></label>
<label class="row"><span>Classics</span><input type="number" id="cFleet" min="0" step="1"></label>
</div>
<p class="hint">The output is what this fleet makes at the set effectiveness and specials share.</p>
</div>
<label class="row" style="margin-top:14px"><span>Splatter and specials, share of output<br><small>plan: 60 % and more; specials run only on Duplex systems</small></span><b id="specV"></b></label>
<input type="range" id="spec" min="0" max="100" step="1" aria-label="Specials share">
<p class="hint">Splatter, multi-colour and effect records run only on the Duplex, with
its EX 35 colour extruder (Newbilt recommends the Duplex for splatter). Black and
single-colour records run on either press.</p>
</section>
<section class="card">
<h2>Plant configuration</h2>
<p class="hint" style="margin:0 0 10px">Each switch changes the investment, the budget answer and the money
a year. Under each option: what it adds to or saves on the capex against the current choice, at the
midpoint with contingency. An outsourced step of the title services is passed through at cost: its
revenue stays, its contribution is zero.</p>
<div id="cfg"></div>
</section>
<section class="card">
<h2>Operation</h2>
<label class="row"><span>Shifts per day</span><input type="number" id="shifts" min="1" max="3" step="1"></label>
<label class="row"><span>Hours per shift</span><input type="number" id="hrs" min="1" max="12" step="0.5"></label>
<label class="row"><span>Working days per year</span><input type="number" id="days" min="1" max="365" step="1"></label>
<label class="row"><span>Overall effectiveness, %<br><small>scale.md: 60 to 70 % (changeovers, stamper changes, QC, breaks)</small></span><input type="number" id="eff" min="1" max="100" step="1"></label>
</section>
<section class="card">
<h2>Prices and money</h2>
<div class="seg" id="case">
<button data-v="adverse">Adverse</button>
<button data-v="central">Central</button>
<button data-v="favourable">Favourable</button>
</div>
<p class="hint" style="margin-bottom:6px">The price and cost ends of the revenue model; editing a figure makes it your own case.</p>
<div class="row" style="margin-top:4px"><span>Capex used for depreciation, payback and the cash view<br><small>adverse takes the high end, central the midpoint, favourable the low end</small></span></div>
<div class="seg" id="capexEnd">
<button data-v="low">Low end</button>
<button data-v="mid">Midpoint</button>
<button data-v="high">High end</button>
</div>
<label class="row"><span>Standard record, USD<br><small>black or single colour; revenue model: 3.50 to 4.60 blended</small></span><input type="number" id="priceStd" min="0" step="0.05"></label>
<label class="row"><span>Specials premium, USD<br><small>splatter and effects: +0.60 to 1.70</small></span><input type="number" id="premSpec" min="0" step="0.05"></label>
<label class="row"><span>Title services, USD a title<br><small>mastering, cutting, stampers, test pressings: 580 to 1,730</small></span><input type="number" id="titleFee" min="0" step="5"></label>
<div class="row" style="margin-top:12px"><span>Show money in</span></div>
<div class="seg" id="cur">
<button data-v="USD">USD</button>
<button data-v="THB">THB</button>
<button data-v="EUR">EUR</button>
</div>
<p class="hint" id="priceHint"></p>
<div class="row" style="margin-top:12px"><span>BOI promotion</span></div>
<div class="seg" id="boi">
<button data-v="on">Promoted</button>
<button data-v="off">Not promoted</button>
</div>
<label class="row"><span>Records exported, %<br><small>exports: no duty on their raw material (BOI), VAT zero-rated</small></span><input type="number" id="exportShare" min="0" max="100" step="5"></label>
</section>
<section class="card">
<h2>Press rates</h2>
<label class="row"><span>Duplex system, records per minute<br><small>both presses together; 3 = 40 s per press cycle. Newbilt quotes about 30 s per press</small></span><input type="number" id="rateD" min="0" step="0.1"></label>
<label class="row"><span>Classic, records per minute<br><small>2 = 30 s cycle; Newbilt flyer: 28 s at 180 g</small></span><input type="number" id="rateC" min="0" step="0.1"></label>
<label class="row"><span>Specials run at, % of the standard rate<br><small>no vendor figure; lower it to test slower splatter runs</small></span><input type="number" id="specF" min="10" max="100" step="5"></label>
</section>
</div>
<div>
<section class="card">
<h2>Fleet</h2>
<div class="kpis" id="fleetK"></div>
<div id="flags"></div>
</section>
<section class="card">
<h2>Budget: Plan A and Plan B</h2>
<p class="note" style="margin-top:0">Two plans for the investor (October 2026): Plan A at ~180M THB keeps every
machine of the launch plant with six cost levers, Plan B at ~215M THB keeps the same launch plant (one Duplex system
and one Classic) with everything the levers cut; both priced without contingency and with the ramp-up reserve,
the second Duplex a growth step in both. Marks at 180M and 180M plus 25 % (225M). Bars: the investment
at the low end, the midpoint and the high end of every block (contingency as set under Plant configuration),
plus the ramp-up reserve when it is in the budget (hatched).</p>
<div class="kpis" id="budK"></div>
<div class="bud" id="bud"></div>
<div id="budV"></div>
</section>
<section class="card">
<h2>Output and machine mix</h2>
<div class="kpis" id="outK"></div>
<div class="bar" id="bar"></div>
<div class="legend" id="legend"></div>
<div class="tscroll" style="margin-top:12px"><table id="mixT"></table></div>
</section>
<section class="card">
<h2>Money a year</h2>
<div class="kpis" id="ecoK"></div>
<div id="boiNote"></div>
<div class="bar" id="ecoBar" style="height:28px"></div>
<div class="legend" id="ecoLeg"></div>
<div class="tscroll" style="margin-top:12px"><table id="ecoT"></table></div>
<p class="note" style="margin-bottom:0">Central is the revenue model's midpoint. The revenue model itself calls
that case optimistic: its EBITDA sits well above the 15 to 25 % pressing plants earn, because the cost
table still misses indirect labour, inbound freight, inventory, effluent and sales commission. Read the
adverse case as the honest one until those lines exist. Prices are a Western benchmark; Thai domestic
invoices run far higher (650 to 950 THB all-in at 300 units).</p>
</section>
<section class="card">
<h2>Ten years of cash</h2>
<p class="note" style="margin-top:0">A sketch, not a forecast: pre-tax, pre-financing, no BOI corporate
income tax holiday modelled, no terminal value. Year 0 is the investment (at the capex end chosen under
Prices and money) plus the ramp-up reserve when it is in the budget; from year 1 the plant sells the
ramp share of its output. Payroll and overheads are paid in full from year 1; materials, energy and
title services follow output.</p>
<div class="inl">
<span>Sold output, % of the mature year <span class="badge est">estimate</span></span>
<label>Y1 <input type="number" data-a="ramp1" min="0" max="150" step="5" aria-label="Year 1 output share"></label>
<label>Y2 <input type="number" data-a="ramp2" min="0" max="150" step="5" aria-label="Year 2 output share"></label>
<label>Y3 <input type="number" data-a="ramp3" min="0" max="150" step="5" aria-label="Year 3 output share"></label>
<label>Y4 on <input type="number" data-a="ramp4" min="0" max="150" step="5" aria-label="Output share from year 4"></label>
<label>Discount rate, % <input type="number" data-a="discRate" min="0" max="50" step="0.5" aria-label="Discount rate"></label>
</div>
<div class="kpis" id="cashK"></div>
<div class="tscroll"><svg id="cashSvg" viewBox="0 0 640 230" role="img" aria-label="Cumulative cash over ten years"></svg></div>
<div class="legend" id="cashLeg"></div>
<div class="tscroll" style="margin-top:10px"><table id="cashT"></table></div>
</section>
<section class="card">
<h2>Investment</h2>
<p class="note" style="margin-top:0">Built from the blocks of total-investment.md (launch plant, October 2026)
for the fleet and configuration above. Vendor prices without Thai duty or import VAT, so BOI promotion is
assumed unless switched off. The default reproduces the launch plant roll-up.</p>
<div class="inl"><label>Contingency, % <input type="number" data-a="contPct" min="0" max="60" step="1" aria-label="Contingency"></label></div>
<div class="kpis" id="ivK"></div>
<div id="ivFlags"></div>
<div class="tscroll" style="margin-top:12px"><table id="ivT"></table></div>
</section>
<section class="card">
<h2>What the fleet needs</h2>
<div class="tscroll"><table id="reqT"></table></div>
</section>
</div>
</div>
<div class="wrap">
<section class="card">
<h2>Machine park and stamper production</h2>
<p class="note" style="margin-top:0">The fleet above as a schematic, not to scale: each Duplex cell as
in the cell sketch (presses either side of the operator, EX 50 and EX 35 behind him, trimmer in
front), each Classic with its built-in extruder; on the right finishing, where the records cool and
one optical station checks them; above it the stamper line that feeds the presses,
sized from the cycle times in galvanics.md; below it the central utilities. Bars show how busy
each station is; an amber box exceeds the plant as planned today.</p>
<div class="tscroll"><svg id="park" viewBox="0 0 1200 600" role="img" aria-label="Machine park and stamper production schematic"></svg></div>
</section>
<section class="card">
<h2>From small to fully equipped</h2>
<p class="note" style="margin-top:0">Fleets from the launch plant to a full Payap press hall (about 870 m²,
laid out for Duplex systems because the plant focuses on special pressings: five Duplex bays
and four Classic positions, ~750 m², balanced by output at 60 % specials), then the same floor filled Classic-led or with
Duplex systems only, for comparison, at the current settings and plant configuration. Click a row to load it.</p>
<div class="tscroll"><table id="ladT"></table></div>
</section>
<section class="card">
<h2>Assumptions</h2>
<p class="note" style="margin-top:0">Every result above comes from these figures. Changed values are
kept in the link, so a scenario can be shared with "Copy link".</p>
<div class="tscroll"><table id="asT"></table></div>
<div class="btns">
<button id="copy">Copy link to this scenario</button>
<button id="reset">Reset everything</button>
</div>
</section>
<section class="card note">
<h2>Basis and limits</h2>
<p><b>Classic utilities.</b> Newbilt has published no power, steam, cooling or weight figures for
the Classic, so it counts here as one HRP 12 press plus one extruder on the central hydraulics.
Cross-check from the Toolex Alpha leaflet the Classic is based on: 1.2 kg steam, 15 l cooling
water, 18 l air and 3.15 l hydraulic oil per cycle (about 144 kg/h of steam at 2 a minute),
2.18 × 1.30 m, 1,850 kg. These go into the re-quote questions in pressing-line.md.</p>
<p><b>EX 35.</b> Counted at the EX 50's electrical, air and water figures; it is the smaller
extruder, so this errs high.</p>
<p><b>Central hydraulics.</b> Newbilt's aggregate serves two presses; the per-press figures are
half of it, summed without any diversity. Whether the Classic can run from the shared plant
is an open question for Newbilt.</p>
<p><b>Peaks add up.</b> Steam, cooling and air are summed at each machine's peak, as Newbilt
states them. A real plant staggers cycles, so the boiler and chiller can be sized lower once
a utility engineer has the cycle data.</p>
<p><b>Money.</b> Prices, per-record costs, wages, overheads and depreciation come from
finance/revenue-model.md, the investment from the blocks of finance/total-investment.md (launch plant,
October 2026) as the configuration selects them, the electricity tariff and PV from scale.md, the biomass
heat price from biomass.md. Electricity and steam follow the fleet: machines, chiller and compressors
at the hours run, steam from the presses' running time, plus an estimated base load for the rest of
the plant. Electric steam pays the PEA energy charge and a demand charge per installed boiler module;
biomass steam pays the biomass heat price. Bought solar PV offsets the bill at no running cost; through
a PPA its yield is paid at the PPA price (to quote). Lacquer blanks (70 EUR each) and the cutting engineer
(50,000 THB a month) are team figures; shipping cartons have no figure on file and are an estimate.
Missing as the revenue model lists: indirect labour, freight, inventory, effluent, commission.</p>
<p><b>Title services.</b> The fee per title (580 / 1,155 / 1,730 USD by case) is split by the artist demos'
per-title price of 37,836 THB: mastering, lacquer and cutting 51.1 %, electroforming 37.0 %, press setup
and test pressings 11.9 %. The steps done in-house earn the revenue model's 60 to 70 % contribution; the
lacquer blanks and plating chemistry are counted as their own lines and the balance up to that cost is
styli, remakes and other title costs (when the two lines alone cost more, the contribution is lower and
the page shows it). <b>An outsourced step is passed through at cost: its revenue stays, its contribution
is zero.</b> Stamper supply to other pressing plants is booked at its contribution only (business plan
draft 0.1: 300 titles at wholesale might net 60k to 100k USD); the wholesale price is not on file.
Cassette and CD-R revenue is illustrative, with no market data and no running costs on file, so it
stays out of the totals unless switched on.</p>
<p><b>Investment.</b> Utilities are built from modules so they follow the fleet: one 500 kg/h boiler module
per 500 kg/h of peak steam, one 250 kW chiller module per 250 kW of peak cooling, on a cooling backbone and
in a boiler hall and chiller compound built for four modules each; the clean-room adder is full with
galvanics and cutting in-house, half with one of them. Freight, installation and training scale with the
machinery the line names (Newbilt, Pheenix, Sibert, Sillitoe), the working capital with the records a
year against the revenue model's 0.84M (never below 60 %). Both scalings are estimates.</p>
<p><b>Ten years of cash.</b> Pre-tax and pre-financing, with no terminal value: it shows how long the
investment takes to come back at the chosen case and ramp, not what the plant is worth.</p>
<p><b>BOI.</b> The capex blocks in total-investment.md are vendor prices without Thai import duty or
VAT, so they already assume BOI promotion: machinery imported under it pays no duty (Investment
Promotion Act section 28) and, with the duty, no import VAT (Revenue Department, BOI OSOS FAQ).
"Not promoted" adds the duty on the imported machinery the configuration selects (presses, galvanics
line, Sibert, cutting room, metrology, ZLD and DI water) to the capex and shows the import VAT as cash
paid at the border, reclaimable as input tax. Raw material: no duty on compound for exported
records, 90 % off for domestic sales. Revenue is net of VAT (exports zero-rated, 7 % on domestic
sales passed on). Corporate income tax and BOI's CIT exemptions (automation, R&D, possibly the battery; solar
installations are no longer promoted since July 2025) are not modelled.</p>
<p><b>Labour.</b> Base salaries per role (revenue-model.md, its loaded rates divided by 1.3; the cutting
engineer a team figure), plus the employer's 5 % social security up to the 17,500 THB ceiling, 25 %
for overtime, bonus and PPE, and the company's health care for every member of staff, office
included: group health insurance, a yearly check-up, and the risk-factor tests Thai law requires
for the press floor, galvanics and maintenance; and unified Engelbert Strauss workwear with the
company logo for everyone, shipped from Germany with Thai duty (worker-safety.md).</p>
<p><b>Not scaled here:</b> admin, sales and logistics wages (in the overheads line); cover for
leave and sickness. <b>Not in the investment</b> (as in total-investment.md): CD replication, financing
costs, currency moves, the archive's mobile shelving (to quote) and the central hydraulic plant around
the Newbilt aggregates (standby pump set, accumulator station, ring main; not costed yet); the ramp-up
reserve is shown on its own line.</p>
<p>Sources in the CNX Vinyl planning repository: equipment/pressing-line.md,
equipment/newbilt/ (Duplex site requirements V14, offer 240620-04, 2026 flyers),
equipment/galvanics.md (cycle times per step; the stamper line counts each station from them,
before remakes and test pressings), scale.md, finance/total-investment.md, finance/utilities-investment.csv,
suppliers/china-utilities.md, quality-automation.md, finance/revenue-model.md, artist-order.html (per-title
price), site-candidates.md (Payap scenario model).</p>
</section>
</div>
<footer>CNX Vinyl planning repository · docs/fleet-calculator.html · figures are planning estimates, October 2026.</footer>
<script>
/* Isometric plant view (iso-plant.js, global CNXIso) */
/*
* iso-plant.js: the CNX Vinyl plant as an isometric "tile game" scene (SVG).
* Plain ES2019, no dependencies, no build step, one global: CNXIso.
*
* var view = CNXIso.mount(containerEl, cfg, { onPick: function (info) {} });
* view.update(cfg); // merges cfg over the current config and redraws
* view.setLevel("gf" | "1f" | "roof"); // ground-floor cutaway, upper floors, roofs
* view.setRotation(0..3); // 1 (the default) shows the factory front
* view.destroy();
*
* Layout: the Payap scenario model (docs/site-candidates.md), the detached
* utility yard (docs/scale.md), crane, workshop and visitor corridor
* (docs/crane-workshop-tours.md), archive shelving (docs/facility-security.md),
* the Duplex cell (docs/equipment/pressing-line.md), galvanics and Sibert
* (docs/equipment/galvanics.md). Schematic: positions and envelopes are
* estimates unless a tooltip names the source of a figure.
*/
(function (root) {
"use strict";
var VERSION = "1.1";
var DEF = {
duplex: 1, classic: 1, galvanics: true, cutting: true, metrology: "full",
cassette: true, cdr: true, solar: "buy", pvKwp: 520, batteryKwh: 271,
fuel: "biomass", boilerModules: 2, chillerModules: 2, crane: "buy",
corridor: true, phase2: false, running: true, labels: true
};
var OFFW = { off: 1, out: 1, bought: 1, no: 1, none: 1, later: 1, "false": 1, "0": 1, "": 1 };
function bool(v, d) {
if (v === undefined || v === null) return d;
if (typeof v === "string") return !OFFW[v.toLowerCase()];
return !!v;
}
function numv(v, d, lo, hi, round) {
var n = Number(v);
if (v === undefined || v === null || v === "" || !isFinite(n)) n = d;
if (round) n = Math.round(n);
return Math.max(lo, Math.min(hi, n));
}
function norm(c) {
c = c || {};
return {
duplex: numv(c.duplex, DEF.duplex, 0, 12, true),
classic: numv(c.classic, DEF.classic, 0, 40, true),
galvanics: bool(c.galvanics, true),
cutting: bool(c.cutting, true),
metrology: c.metrology === "launch" ? "launch" : "full",
cassette: bool(c.cassette, true),
cdr: bool(c.cdr, true),
solar: c.solar === "ppa" || c.solar === "none" ? c.solar : "buy",
pvKwp: numv(c.pvKwp, DEF.pvKwp, 0, 3000, false),
batteryKwh: numv(c.batteryKwh, DEF.batteryKwh, 0, 5000, false),
fuel: c.fuel === "biomass" ? "biomass" : "electric",
boilerModules: numv(c.boilerModules, DEF.boilerModules, 0, 12, true),
chillerModules: numv(c.chillerModules, DEF.chillerModules, 0, 12, true),
crane: c.crane === "rent" ? "rent" : "buy",
corridor: bool(c.corridor, true),
phase2: bool(c.phase2, false),
running: bool(c.running, true),
labels: bool(c.labels, true)
};
}
function fmt(n) { return Math.round(n).toLocaleString("en-US"); }
function esc(s) {
return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
function r1(v) { return Math.round(v * 10) / 10; }
function hash(i) { var x = Math.sin(i * 12.9898 + 78.233) * 43758.5453; return x - Math.floor(x); }
// ------------------------------------------------------------ projection
// World: x east, y north, z up, metres. View coordinates (vx, vy): the
// viewer sits at +vx +vy, so faces facing +vy show on the left (mid tone),
// faces facing +vx on the right (dark). Four 90 degree view rotations.
var C = 4, CZ = 4, SQ2 = Math.SQRT2;
var ROT = [
function (x, y) { return [x, -y]; }, // from the south-east
function (x, y) { return [-y, -x]; }, // from the south-west
function (x, y) { return [-x, y]; }, // from the north-west
function (x, y) { return [y, x]; } // from the north-east
];
function Proj(rot) { this.rot = ((rot % 4) + 4) % 4; this.R = ROT[this.rot]; }
Proj.prototype.s = function (x, y, z) {
var v = this.R(x, y);
return [(v[0] - v[1]) * C, (v[0] + v[1]) * C / 2 - z * CZ];
};
Proj.prototype.d = function (pts, open) {
var o = "", i, s;
for (i = 0; i < pts.length; i++) { s = this.s(pts[i][0], pts[i][1], pts[i][2]); o += (i ? "L" : "M") + r1(s[0]) + " " + r1(s[1]); }
return open ? o : o + "Z";
};
Proj.prototype.vis = function (nx, ny) { var v = this.R(nx, ny); return v[0] + v[1] > 1e-6 ? v : null; };
Proj.prototype.dv = function (dx, dy, dz) {
var a = this.s(dx, dy, dz || 0), b = this.s(0, 0, 0);
return [a[0] - b[0], a[1] - b[1]];
};
Proj.prototype.depth = function (x, y) { var v = this.R(x, y); return v[0] + v[1]; };
function shade(v) {
var a = Math.atan2(v[1], v[0]) * 180 / Math.PI;
return a > 67.5 ? "iso-l" : a < 22.5 ? "iso-r" : "iso-c";
}
function rect(x0, y0, x1, y1) { return [[x0, y0], [x1, y0], [x1, y1], [x0, y1]]; }
function did(id) { return id ? ' data-id="' + id + '"' : ""; }
// prism with a convex ccw footprint; z0/z1 numbers or one value per vertex
function prismS(P, pts, z0, z1, mat, o) {
o = o || {};
var n = pts.length, zt = [], zb = [], i, a, b, v, out = "", at = did(o.id), tp = [];
for (i = 0; i < n; i++) { zt.push(typeof z1 === "number" ? z1 : z1[i]); zb.push(typeof z0 === "number" ? z0 : z0[i]); }
if (!o.noSide) {
for (i = 0; i < n; i++) {
a = pts[i]; b = pts[(i + 1) % n];
v = P.vis(b[1] - a[1], a[0] - b[0]);
if (!v) continue;
var j = (i + 1) % n;
if (zt[i] - zb[i] < 1e-6 && zt[j] - zb[j] < 1e-6) continue;
out += '<path class="' + (o.side || mat) + " " + shade(v) + '"' + at + ' d="' +
P.d([[a[0], a[1], zb[i]], [b[0], b[1], zb[j]], [b[0], b[1], zt[j]], [a[0], a[1], zt[i]]]) + '"/>';
}
}
if (!o.noTop) {
for (i = 0; i < n; i++) tp.push([pts[i][0], pts[i][1], zt[i]]);
out += '<path class="' + (o.top || mat) + ' iso-t"' + at + ' d="' + P.d(tp) + '"/>';
}
return out;
}
function cylS(P, cx, cy, r, z0, z1, mat, o) {
o = o || {};
var b = P.s(cx, cy, z0), t = P.s(cx, cy, z1), rx = r * C * SQ2, ry = rx / 2, at = did(o.id), s = "";
var bx = r1(b[0]), by = r1(b[1]), ty = r1(t[1]), L = r1(b[0] - rx), R = r1(b[0] + rx), RX = r1(rx), RY = r1(ry);
var fb = r1(b[1] + ry), ft = r1(t[1] + ry);
if (z1 > z0) {
s += '<path class="' + (o.side || mat) + ' iso-l"' + at + ' d="M' + L + " " + ty + "L" + L + " " + by + "A" + RX + " " + RY +
" 0 0 0 " + bx + " " + fb + "L" + bx + " " + ft + "A" + RX + " " + RY + " 0 0 1 " + L + " " + ty + 'Z"/>';
s += '<path class="' + (o.side || mat) + ' iso-r"' + at + ' d="M' + bx + " " + fb + "A" + RX + " " + RY + " 0 0 0 " + R + " " + by +
"L" + R + " " + ty + "A" + RX + " " + RY + " 0 0 1 " + bx + " " + ft + 'Z"/>';
}
if (!o.noTop) s += '<ellipse class="' + (o.top || mat) + ' iso-t"' + at + ' cx="' + bx + '" cy="' + ty + '" rx="' + RX + '" ry="' + RY + '"/>';
return s;
}
// frustum (cone) with n sides: radius r0 at z0, r1 at z1
function frustS(P, cx, cy, r0, r1_, z0, z1, mat, n, o) {
o = o || {};
n = n || 10;
var i, a0, a1, out = "", top = [], v, at = did(o.id);
for (i = 0; i < n; i++) {
a0 = i / n * Math.PI * 2; a1 = (i + 1) / n * Math.PI * 2;
var am = (a0 + a1) / 2;
v = P.vis(Math.cos(am), Math.sin(am));
top.push([cx + r1_ * Math.cos(a0), cy + r1_ * Math.sin(a0), z1]);
if (!v) continue;
out += '<path class="' + mat + " " + shade(v) + '"' + at + ' d="' + P.d([
[cx + r0 * Math.cos(a0), cy + r0 * Math.sin(a0), z0], [cx + r0 * Math.cos(a1), cy + r0 * Math.sin(a1), z0],
[cx + r1_ * Math.cos(a1), cy + r1_ * Math.sin(a1), z1], [cx + r1_ * Math.cos(a0), cy + r1_ * Math.sin(a0), z1]]) + '"/>';
}
if (r1_ > 0.01 && !o.noTop) out += '<path class="' + mat + ' iso-t"' + at + ' d="' + P.d(top) + '"/>';
return out;
}
// openings (windows, doors) on the vertical face through A -> B (outward normal on the right)
function openS(P, A, B, list, cls, id) {
var vv = list.length ? P.vis(B[1] - A[1], A[0] - B[0]) : null;
if (!vv) return "";
var dx = B[0] - A[0], dy = B[1] - A[1], L = Math.sqrt(dx * dx + dy * dy), ux = dx / L, uy = dy / L, d = "", i, q;
for (i = 0; i < list.length; i++) {
q = list[i];
d += P.d([[A[0] + ux * q[0], A[1] + uy * q[0], q[2]], [A[0] + ux * q[1], A[1] + uy * q[1], q[2]],
[A[0] + ux * q[1], A[1] + uy * q[1], q[3]], [A[0] + ux * q[0], A[1] + uy * q[0], q[3]]]);
}
return '<path class="' + cls + " " + shade(vv) + '"' + did(id) + ' d="' + d + '"/>';
}
function sides(x0, y0, x1, y1) {
return { S: [[x0, y0], [x1, y0]], E: [[x1, y0], [x1, y1]], N: [[x1, y1], [x0, y1]], W: [[x0, y1], [x0, y0]] };
}
function flatS(P, pts, z, cls, id, extra) {
var p3 = [], i;
for (i = 0; i < pts.length; i++) p3.push([pts[i][0], pts[i][1], z]);
return '<path class="' + cls + '"' + did(id) + (extra || "") + ' d="' + P.d(p3) + '"/>';
}
function linesS(P, segs, z, cls) { // segs: [[x0,y0,x1,y1], ...]
var d = "", i, a, b;
for (i = 0; i < segs.length; i++) {
a = P.s(segs[i][0], segs[i][1], z); b = P.s(segs[i][2], segs[i][3], z);
d += "M" + r1(a[0]) + " " + r1(a[1]) + "L" + r1(b[0]) + " " + r1(b[1]);
}
return d ? '<path class="' + cls + '" d="' + d + '"/>' : "";
}
function discS(P, cx, cy, z, r, cls, id) {
var s = P.s(cx, cy, z), rx = r * C * SQ2;
return '<ellipse class="' + cls + '"' + did(id) + ' cx="' + r1(s[0]) + '" cy="' + r1(s[1]) + '" rx="' + r1(rx) + '" ry="' + r1(rx / 2) + '"/>';
}
// ------------------------------------------------------------ sprites
function Sprite(sc, id, cls) {
this.sc = sc; this.P = sc.P; this.id = id || ""; this.cls = cls || ""; this.h = [];
this.bb = [1e9, 1e9, -1e9, -1e9, 1e9, -1e9]; this.sb = [1e9, 1e9, -1e9, -1e9];
}
var SP = Sprite.prototype;
SP.ext = function (x, y, z) {
var v = this.P.R(x, y), b = this.bb, s = this.P.s(x, y, z), q = this.sb;
if (v[0] < b[0]) b[0] = v[0]; if (v[1] < b[1]) b[1] = v[1];
if (v[0] > b[2]) b[2] = v[0]; if (v[1] > b[3]) b[3] = v[1];
if (z < b[4]) b[4] = z; if (z > b[5]) b[5] = z;
if (s[0] < q[0]) q[0] = s[0]; if (s[1] < q[1]) q[1] = s[1];
if (s[0] > q[2]) q[2] = s[0]; if (s[1] > q[3]) q[3] = s[1];
return this;
};
SP.extBox = function (x0, y0, x1, y1, z0, z1) {
this.ext(x0, y0, z0).ext(x1, y0, z0).ext(x1, y1, z0).ext(x0, y1, z0);
return this.ext(x0, y0, z1).ext(x1, y0, z1).ext(x1, y1, z1).ext(x0, y1, z1);
};
SP.box = function (x0, y0, x1, y1, z0, z1, mat, o) {
this.extBox(x0, y0, x1, y1, z0, z1);
this.h.push(prismS(this.P, rect(x0, y0, x1, y1), z0, z1, mat, o));
return this;
};
SP.prism = function (pts, z0, z1, mat, o) {
for (var i = 0; i < pts.length; i++) {
this.ext(pts[i][0], pts[i][1], typeof z0 === "number" ? z0 : z0[i]);
this.ext(pts[i][0], pts[i][1], typeof z1 === "number" ? z1 : z1[i]);
}
this.h.push(prismS(this.P, pts, z0, z1, mat, o));
return this;
};
SP.cyl = function (cx, cy, r, z0, z1, mat, o) {
this.extBox(cx - r, cy - r, cx + r, cy + r, z0, z1);
this.h.push(cylS(this.P, cx, cy, r, z0, z1, mat, o));
return this;
};
SP.frust = function (cx, cy, r0, r1_, z0, z1, mat, n, o) {
var r = Math.max(r0, r1_);
this.extBox(cx - r, cy - r, cx + r, cy + r, z0, z1);
this.h.push(frustS(this.P, cx, cy, r0, r1_, z0, z1, mat, n, o));
return this;
};
SP.open = function (A, B, list, cls, id) { this.h.push(openS(this.P, A, B, list, cls, id)); return this; };
SP.flat = function (pts, z, cls, id, extra) { this.h.push(flatS(this.P, pts, z, cls, id, extra)); return this; };
SP.lines = function (segs, z, cls) { this.h.push(linesS(this.P, segs, z, cls)); return this; };
SP.disc = function (cx, cy, z, r, cls, id) { this.h.push(discS(this.P, cx, cy, z, r, cls, id)); return this; };
SP.raw = function (s) { this.h.push(s); return this; };
SP.ga = function (cls, style) { this.h.push('<g class="' + cls + '"' + (style ? ' style="' + style + '"' : "") + ">"); return this; };
SP.ge = function () { this.h.push("</g>"); return this; };
// draw items in back-to-front order of their plan centre: [[x, y, fn], ...]
SP.seq = function (items) {
var P = this.P, self = this;
items.slice().sort(function (a, b) { return P.depth(a[0], a[1]) - P.depth(b[0], b[1]); })
.forEach(function (it) { it[2](self); });
return this;
};
SP.person = function (x, y, z, cls) {
this.cyl(x, y, 0.2, z, z + 1.2, cls || "iso-hivis", { noTop: true });
var s = this.P.s(x, y, z + 1.45);
this.ext(x, y, z + 1.65);
this.h.push('<circle class="iso-headc" cx="' + r1(s[0]) + '" cy="' + r1(s[1]) + '" r="' + r1(0.2 * C * 1.2) + '"/>');
return this;
};
SP.led = function (x, y, z, cls) {
var s = this.P.s(x, y, z);
this.h.push('<circle class="iso-led ' + (cls || "") + '" cx="' + r1(s[0]) + '" cy="' + r1(s[1]) + '" r="0.9"/>');
return this;
};
SP.puffs = function (x, y, z, kind, n, dur) {
var s = this.P.s(x, y, z), i, h = '<g class="iso-vent" transform="translate(' + r1(s[0]) + " " + r1(s[1]) + ')">';
n = n || 3; dur = dur || 3.2;
for (i = 0; i < n; i++) {
h += '<circle class="iso-a ' + (kind || "iso-puff") + '" r="' + (kind === "iso-smoke" ? 3.2 : kind === "iso-puff2" ? 2.4 : 1.5) +
'" style="animation-delay:-' + (i * dur / n + hash(x + y + i) * 0.4).toFixed(2) + 's;animation-duration:' + dur + 's"/>';
}
this.h.push(h + "</g>");
return this;
};
SP.fan = function (x, y, z, r) {
var P = this.P, s = P.s(x, y, z), w = 0.12;
this.h.push(discS(P, x, y, z, r, "iso-fanring"));
this.h.push('<g transform="matrix(' + C + " " + C / 2 + " " + (-C) + " " + C / 2 + " " + r1(s[0]) + " " + r1(s[1]) + ')"><g class="iso-a iso-fan">' +
'<path d="M' + (-r * 0.85) + " " + (-w) + "H" + r * 0.85 + "V" + w + "H" + (-r * 0.85) + "ZM" + (-w) + " " + (-r * 0.85) + "V" + r * 0.85 + "H" + w + "V" + (-r * 0.85) + 'Z"/></g></g>');
return this;
};
function Scene(rot, uid) {
this.P = new Proj(rot); this.uid = uid || 0;
this.ground = []; this.sp = []; this.tags = []; this.info = {}; this.flags = []; this.dim = false;
}
// while sc.dim is set (1F level: single-storey buildings as context) sprites are ghosted and carry no tags
Scene.prototype.sprite = function (id, cls) {
var s = new Sprite(this, id, this.dim ? (cls ? cls + " " : "") + "iso-dim" : cls); this.sp.push(s); return s;
};
Scene.prototype.g = function (s) { this.ground.push(s); };
Scene.prototype.inf = function (id, name, detail) { this.info[id] = { id: id, name: name, detail: detail || "" }; };
Scene.prototype.tag = function (x, y, z, text, prio, id, kind) {
if (this.dim) return;
this.tags.push({ x: x, y: y, z: z, t: text, p: prio || 1, id: id || "", k: kind || "" });
};
// ------------------------------------------------------------ depth sort
function cmpBB(a, b) {
var e = 1e-6;
if (a[2] <= b[0] + e) return -1; if (b[2] <= a[0] + e) return 1;
if (a[3] <= b[1] + e) return -1; if (b[3] <= a[1] + e) return 1;
if (a[5] <= b[4] + e) return -1; if (b[5] <= a[4] + e) return 1;
var ka = a[0] + a[2] + a[1] + a[3], kb = b[0] + b[2] + b[1] + b[3];
return ka < kb ? -1 : ka > kb ? 1 : 0;
}
function sortSprites(list) {
var n = list.length, adj = [], indeg = [], key = [], done = [], out = [], i, j, a, b, c, k, best;
for (i = 0; i < n; i++) {
adj.push([]); indeg.push(0); done.push(false);
b = list[i].bb; key.push((b[0] + b[2]) / 2 + (b[1] + b[3]) / 2 + (b[4] + b[5]) * 0.05);
}
for (i = 0; i < n; i++) {
a = list[i];
for (j = i + 1; j < n; j++) {
b = list[j];
if (a.sb[2] <= b.sb[0] || b.sb[2] <= a.sb[0] || a.sb[3] <= b.sb[1] || b.sb[3] <= a.sb[1]) continue;
c = cmpBB(a.bb, b.bb);
if (c < 0) { adj[i].push(j); indeg[j]++; } else if (c > 0) { adj[j].push(i); indeg[i]++; }
}
}
for (k = 0; k < n; k++) {
best = -1;
for (i = 0; i < n; i++) if (!done[i] && indeg[i] === 0 && (best < 0 || key[i] < key[best])) best = i;
if (best < 0) for (i = 0; i < n; i++) if (!done[i] && (best < 0 || indeg[i] < indeg[best] || (indeg[i] === indeg[best] && key[i] < key[best]))) best = i;
done[best] = true; out.push(list[best]);
for (j = 0; j < adj[best].length; j++) indeg[adj[best][j]]--;
}
return out;
}
// ------------------------------------------------------------ plant geometry
// Factory 128 x 25 m (Payap scenario model), floor +1.2 m, monopitch roof
// with the low eave (7.0 m) on the car-park side and 11.4 m at the back.
var FX = 8, FY = 69, FL = 128, FW = 25, FFL = 1.2, EAVE = 7.0, HIGH = 11.4;
function roofZ(fy) { return FFL + EAVE + (HIGH - EAVE) * fy / FW; }
function X(fx) { return FX + fx; }
function Y(fy) { return FY + fy; }
// Press hall: 2 rows, 8 m deep. Slot lengths follow the calculator's floor
// figures (110 m2 per Duplex bay, 50 m2 per Classic, 870 m2 hall). Laid out for Duplex
// systems (60 %+ specials, October 2026): 5 Duplex bays + 4 Classic positions, ~73/27 by floor.
var HALL_D = 5, HALL_C = 4;
var HALL = { cap: 870, aD: 110, aC: 50 };
var HROWS = [{ x: X(40), y0: Y(3.6), y1: Y(11.6), len: 52 }, { x: X(47.8), y0: Y(12.4), y1: Y(20.4), len: 44.2 }];
var LTOT = 52 + 44.2, WD = HALL.aD / HALL.cap * LTOT, WC = HALL.aC / HALL.cap * LTOT;
var P2 = { x0: 54, y0: 31, x1: 96, y1: 57, eave: 7.0, high: 9.4 };
var P2ROWS = [{ x: 55.5, y0: 34, y1: 42, len: 39 }, { x: 55.5, y0: 43, y1: 51, len: 39 }];
var OFC = { x0: 14, y0: 42, x1: 46, y1: 57, z0: 0.3, z1: 8.3 };
// covered link office lobby -> factory entrance; doors at both ends are centred on it
var LINK = { x0: 15.5, x1: 18.5 };
function tryPlace(list, rows, f, partial) {
var out = [], ri = 0, pos = 0, i, w, it, lost = 0;
for (i = 0; i < list.length; i++) {
it = list[i]; w = (it.t === "D" ? WD : WC) * f;
while (ri < rows.length && pos + w > rows[ri].len + 1e-6) { ri++; pos = 0; }
if (ri >= rows.length) {
if (!partial) return null;
for (; i < list.length; i++) if (!list[i].e) lost++;
break;
}
out.push({ t: it.t, n: it.n, e: it.e, x0: rows[ri].x + pos, x1: rows[ri].x + pos + w, y0: rows[ri].y0, y1: rows[ri].y1 });
pos += w;
}
return partial ? { placed: out, lost: lost } : out;
}
function placeSlots(items, rows) {
var list = items.slice(), f = 1, res, i, k;
for (;;) {
res = tryPlace(list, rows, f, false);
if (res) return { placed: res, lost: 0 };
k = -1;
for (i = list.length - 1; i >= 0; i--) if (list[i].e) { k = i; break; }
if (k >= 0) { list.splice(k, 1); continue; }
if (f > 0.83) { f -= 0.02; continue; }
return tryPlace(list, rows, f, true);
}
}
function allocate(cfg) {
var D = cfg.duplex, Cn = cfg.classic, A = 0, Dh = 0, Ch = 0, i, items = [];
while (Dh < D && A + HALL.aD <= HALL.cap + 1e-9) { Dh++; A += HALL.aD; }
while (Ch < Cn && A + HALL.aC <= HALL.cap + 1e-9) { Ch++; A += HALL.aC; }
var eD = Math.max(0, HALL_D - Dh); while (eD > 0 && A + eD * HALL.aD > HALL.cap) eD--;
var eC = Math.max(0, HALL_C - Ch); while (eC > 0 && A + eD * HALL.aD + eC * HALL.aC > HALL.cap) eC--;
for (i = 0; i < Dh; i++) items.push({ t: "D", n: i + 1 });
for (i = 0; i < eD; i++) items.push({ t: "D", e: 1 });
for (i = 0; i < Ch; i++) items.push({ t: "C", n: i + 1 });
for (i = 0; i < eC; i++) items.push({ t: "C", e: 1 });
var hall = placeSlots(items, HROWS);
var p2i = [];
for (i = Dh; i < D; i++) p2i.push({ t: "D", n: i + 1 });
for (i = Ch; i < Cn; i++) p2i.push({ t: "C", n: i + 1 });
var need = p2i.length > 0, show = need || cfg.phase2, p2 = { placed: [], lost: 0 };
if (show) { for (i = 0; i < 16; i++) p2i.push({ t: "C", e: 1 }); p2 = placeSlots(p2i, P2ROWS); }
return { hall: hall.placed, p2: p2.placed, p2show: show, p2need: need, ovD: D - Dh, ovC: Cn - Ch, Dh: Dh, Ch: Ch,
lost: hall.lost + p2.lost };
}
// paved areas trucks and cars drive on: [x0, y0, x1, y1]
var PAVED = {
gateRoad: [118, -6, 130, 37], apron: [100, 37, 148, 69], eastRoad: [136, 69, 148, 100], lane: [4, 94, 148, 100],
access: [54, 6, 118, 13], parking: [12, 3, 54, 36], yard: [19, 100, 136, 114]
};
// street lamps stand on kerbs, fences and walls, never on a paved drive area (one list feeds the poles
// and the dusk light pools): gate road west kerb, the apron's west, south and east kerbs, the east road,
// the access road, the car park's west kerb, the lane's north kerb at the ramp, behind the utility yard.
// (Earlier spots stood in the middle of the truck apron, in the lane and in the yard.)
var LAMPS = [[116.5, 15], [116.5, 32], [136, 36.2], [148.8, 62], [99.2, 62], [148.8, 86], [56, 15], [11.2, 34], [50, 114.6], [88, 114.6], [8.5, 100.8]];
// ------------------------------------------------------------ PV placement
var PV_ROWS = [1, 3, 5, 7, 10, 12, 14, 16, 19, 21];
// kWp per drawn module place (~2 m2): the 1,220 places on the factory roof are the ~520 kWp array of the
// plans (decided October 2026: PV on the factory only; the office roof and the canopies are PV-ready and
// take panels only above that size)
var PV_MOD = 0.4262;
function pvFields(cfg, al) {
var f = [];
// the factory roof fills first; the PV-ready office roof and parking canopies take any kWp above it
f.push({ key: "roofW", x0: X(1), cols: 103, y0: FY, rows: PV_ROWS, z: function (y) { return roofZ(y - FY) + 0.12; } });
f.push({ key: "roofE", x0: X(107.25), cols: 19, y0: FY, rows: PV_ROWS, z: function (y) { return roofZ(y - FY) + 0.12; } });
f.push({ key: "cars", x0: 18.35, cols: 27, y0: 6, rows: [0.15, 2.45, 4.75], z: function (y) { return 2.9 + (y - 6) / 7 * 1.2 + 0.08; } });
f.push({ key: "scoot", x0: 20.35, cols: 13, y0: 26, rows: [0.4, 2.85], z: function (y) { return 2.45 + (y - 26) / 5.5 * 1.05 + 0.08; } });
// office roof: flat, the three AC units in the middle keep a clear strip around them
var oz = function () { return OFC.z1 + 0.14; };
f.push({ key: "offW", x0: OFC.x0 + 1, cols: 18, y0: OFC.y0, rows: [0.8, 3.1, 5.4, 7.7, 10.0, 12.3], z: oz });
f.push({ key: "offM", x0: OFC.x0 + 20, cols: 7, y0: OFC.y0, rows: [0.8, 12.3], z: oz });
f.push({ key: "offE", x0: OFC.x0 + 28, cols: 3, y0: OFC.y0, rows: [0.8, 3.1, 5.4, 7.7, 10.0, 12.3], z: oz });
if (al.p2show) f.push({ key: "p2", x0: P2.x0 + 1, cols: 40, y0: P2.y0, rows: [1, 3, 5, 7, 10, 12, 14, 16, 19, 21, 23],
z: function (y) { return FFL + P2.eave + (P2.high - P2.eave) * (y - P2.y0) / (P2.y1 - P2.y0) + 0.12; } });
return f;
}
function pvPlace(cfg, al) {
var want = cfg.solar === "none" ? 0 : Math.round(cfg.pvKwp / PV_MOD), left = want, out = {}, fields = pvFields(cfg, al), i, k, r, c;
for (i = 0; i < fields.length; i++) {
var F = fields[i], list = [];
for (r = 0; r < F.rows.length && left > 0; r++) {
for (c = 0; c < F.cols && left > 0; c++) { list.push([F.x0 + c, F.y0 + F.rows[r]]); left--; }
}
out[F.key] = { F: F, panels: list };
}
k = 0; for (i in out) k += out[i].panels.length;
return { want: want, placed: k, left: left, by: out };
}
function pvPath(P, F, panels) {
var d = "", i, x, y;
for (i = 0; i < panels.length; i++) {
x = panels[i][0]; y = panels[i][1];
d += P.d([[x + 0.03, y + 0.03, F.z(y + 0.03)], [x + 0.97, y + 0.03, F.z(y + 0.03)], [x + 0.97, y + 1.97, F.z(y + 1.97)], [x + 0.03, y + 1.97, F.z(y + 1.97)]]);
}
return d;
}
function pvDraw(s, F, panels) {
if (!panels.length) return;
var P = s.P, d = pvPath(P, F, panels), g = "", i, idx, x, y;
s.raw('<path class="iso-pv"' + did("pv") + ' d="' + d + '"/>');
for (i = 0; i < Math.min(8, Math.ceil(panels.length / 40)); i++) {
idx = Math.floor(hash(i * 7 + panels.length) * panels.length);
x = panels[idx][0]; y = panels[idx][1];
g += '<path class="iso-a iso-glint" style="animation-delay:-' + (i * 1.7 + hash(i) * 3).toFixed(1) + 's" d="' +
P.d([[x + 0.1, y + 0.2, F.z(y) + 0.02], [x + 0.9, y + 0.2, F.z(y) + 0.02], [x + 0.9, y + 1.8, F.z(y + 1.8) + 0.02], [x + 0.1, y + 1.8, F.z(y + 1.8) + 0.02]]) + '"/>';
}
s.raw(g);
}
// ------------------------------------------------------------ machines
function pressM(sc, id, cx, cy, z, rot45, running) {
var s = sc.sprite(id);
function foot(r) { return rot45 ? [[cx, cy - r], [cx + r, cy], [cx, cy + r], [cx - r, cy]] : rect(cx - r, cy - r, cx + r, cy + r); }
var k = rot45 ? SQ2 : 1;
s.prism(foot(0.475 * k), z, z + 0.95, "iso-press");
s.prism(foot(0.33 * k), z + 0.95, z + 2.05, "iso-press2");
s.prism(foot(0.43 * k), z + 2.05, z + 2.3, "iso-steel");
var P = sc.P, best = null, f = foot(0.4 * k);
f.forEach(function (p) { if (!best || P.depth(p[0], p[1]) > P.depth(best[0], best[1])) best = p; });
s.led(best[0], best[1], z + 0.97, "iso-ledr");
if (running) s.puffs(cx, cy, z + 2.4, "iso-puff", 2, 3.6);
return s;
}
function extruderM(sc, id, x0, y0, x1, y1, z, h, mat, hr) {
var s = sc.sprite(id);
s.box(x0, y0, x1, y1, z, z + h, mat);
var cx = (x0 + x1) / 2, P = sc.P;
var hy = P.depth(0, 1) > 0 ? y1 - hr - 0.08 : y0 + hr + 0.08; // hopper at the far end from the viewer
s.cyl(cx, hy, hr, z + h, z + h + 0.5, "iso-hopper");
s.led(cx, (y0 + y1) / 2, z + h + 0.02, "iso-ledo");
return s;
}
// optical QC station (equipment/inline-optical-qc.md, FreeCAD concept model): 1.9 x 0.65 x 1.5 m aluminium
// profile frame with the rim-belt conveyor, a light-tight tunnel over the cameras, stack light on the tunnel
// exit and the reject bin beside the outfeed; records run toward -x, the bin stands on the -y side.
// Launch setup (October 2026): one station for both presses, at the start of finishing, after the cooling racks
function qcStation(sc, id, xa, ya, z, tag) {
var s = sc.sprite(id), xb = xa + 1.9, yb = ya + 0.65;
s.seq([
[xa + 0.25, ya - 0.2, function (q) { q.box(xa + 0.05, ya - 0.4, xa + 0.45, ya, z, z + 0.55, "iso-bin"); }],
[xa + 0.95, ya + 0.33, function (q) {
q.box(xa, ya, xb, yb, z, z + 0.9, "iso-optic");
q.lines([[xa + 0.03, ya + 0.18, xb - 0.03, ya + 0.18], [xa + 0.03, yb - 0.18, xb - 0.03, yb - 0.18]], z + 0.905, "iso-qcbelt");
q.seq([[xa + 0.92, ya + 0.33, function (r) { r.box(xa + 0.45, ya + 0.02, xa + 1.4, yb - 0.02, z + 0.9, z + 1.5, "iso-dome"); r.led(xa + 0.55, yb - 0.1, z + 1.52, "iso-ledg"); }],
[xa + 1.65, ya + 0.33, function (r) { r.disc(xa + 1.65, ya + 0.33, z + 0.91, 0.15, "iso-vinylc"); }]]);
}]
]);
if (tag) sc.tag(xa + 0.95, ya + 0.33, z + 2.1, tag, 3, id);
return s;
}
function duplexCell(sc, n, cx, cy, z, running) {
var id = "duplex-" + n;
pressM(sc, id, cx - 1.75, cy - 0.1, z, true, running);
pressM(sc, id, cx + 1.75, cy - 0.1, z, true, running);
extruderM(sc, id, cx - 0.62, cy + 0.55, cx - 0.14, cy + 1.85, z, 1.25, "iso-ex50", 0.22);
extruderM(sc, id, cx + 0.1, cy + 0.75, cx + 0.42, cy + 1.7, z, 1.05, "iso-ex35", 0.16);
var t = sc.sprite(id);
t.box(cx + 0.75, cy - 1.65, cx + 1.35, cy - 1.05, z, z + 1.0, "iso-steel");
t.disc(cx + 1.05, cy - 1.35, z + 1.02, 0.22, "iso-vinylc");
sc.sprite(id).person(cx, cy - 0.35, z);
sc.inf(id, "Duplex system " + n + ": 2 HRP 12 presses, EX 50 + EX 35",
"One operator's cell (~5 x 3 m): presses turned 45 degrees to the operator, EX 50 main and EX 35 colour extruder behind him, trimmer front-left; trimmed records go on spindle stacks to the cooling racks. HRP 12 ~150 t; steam 150 to 180 kg/h and cooling up to 125 kW per press (Newbilt).");
}
function classicM(sc, n, cx, cy, z, running) {
var id = "classic-" + n, s = sc.sprite(id), P = sc.P;
s.box(cx - 1.1, cy - 0.65, cx + 1.1, cy + 0.65, z, z + 1.45, "iso-classic");
s.seq([
[cx - 0.55, cy, function (q) { q.box(cx - 1.0, cy - 0.5, cx - 0.1, cy + 0.5, z + 1.45, z + 2.55, "iso-press2", { top: "iso-steel" }); }],
[cx + 0.6, cy + 0.25, function (q) { q.cyl(cx + 0.6, cy + 0.25, 0.22, z + 1.45, z + 2.0, "iso-hopper"); }],
[cx + 0.6, cy - 0.3, function (q) { q.disc(cx + 0.6, cy - 0.3, z + 1.47, 0.24, "iso-vinylc"); }]
]);
var p = P.depth(1, 0) > 0 ? cx + 1.0 : cx - 1.0;
s.led(p, cy, z + 1.47, "iso-ledg");
if (running) s.puffs(cx - 0.55, cy, z + 2.6, "iso-puff", 2, 3.9);
sc.inf(id, "Classic " + n + ": Newbilt Classic, fully automatic",
"Built-in extruder, automatic cake loading, label feed and spindle stacking; 28 s cycle at 180 g, ~128 records/h (vendor flyer); the stacks go to the cooling racks.");
}
// ------------------------------------------------------------ the site
function siteGround(sc, cfg, al) {
var P = sc.P, g = [];
g.push(prismS(P, rect(-8, -24, 158, 126), -3, 0, "iso-dirt", { top: "iso-g-grass" }));
var d = "", x, y, a, b;
for (x = 0; x <= 150; x += 10) { a = P.s(x, -24, 0); b = P.s(x, 126, 0); d += "M" + r1(a[0]) + " " + r1(a[1]) + "L" + r1(b[0]) + " " + r1(b[1]); }
for (y = -20; y <= 120; y += 10) { a = P.s(-8, y, 0); b = P.s(158, y, 0); d += "M" + r1(a[0]) + " " + r1(a[1]) + "L" + r1(b[0]) + " " + r1(b[1]); }
g.push('<path class="iso-grid" d="' + d + '"/>');
// public road (assumed on the south side) and the plot paving
g.push(flatS(P, rect(-8, -16, 158, -6), 0, "iso-g-asph iso-t", "road"));
g.push(linesS(P, [[-8, -11, 158, -11]], 0, "iso-dash"));
g.push(linesS(P, [[-8, -6.3, 158, -6.3], [-8, -15.7, 158, -15.7]], 0, "iso-kerb"));
var pr = function (k) { var r = PAVED[k]; return rect(r[0], r[1], r[2], r[3]); };
g.push(flatS(P, pr("gateRoad"), 0, "iso-g-asph iso-t", "apron"));
g.push(flatS(P, pr("apron"), 0, "iso-g-conc iso-t", "apron"));
g.push(flatS(P, pr("eastRoad"), 0, "iso-g-asph iso-t", "apron"));
g.push(flatS(P, pr("lane"), 0, "iso-g-asph iso-t", "lane"));
g.push(flatS(P, pr("access"), 0, "iso-g-asph iso-t", "road"));
g.push(flatS(P, pr("parking"), 0, "iso-g-asph iso-t", "parking"));
// footpath from the car park to the office entrance (lobby at the west end, floor plans)
g.push(flatS(P, rect(15.5, 36, 18, 42), 0, "iso-g-path iso-t", "office"));
g.push(flatS(P, rect(15.5, 57, 18.5, 69), 0, "iso-g-path iso-t", "link"));
g.push(flatS(P, pr("yard"), 0, "iso-g-conc iso-t", "yard"));
g.push(flatS(P, rect(91.6, 100.6, 104.9, 112.2), 0.01, "iso-g-pad iso-t", "chillers"));
if (al.p2show) g.push(flatS(P, rect(72, 57, 76, 69), 0, "iso-g-path iso-t", "phase2"));
else g.push(flatS(P, rect(P2.x0, P2.y0, P2.x1, P2.y1), 0.01, "iso-reserve", "phase2"));
// markings: gate road centre line, parking bays, dock stripes, scooter bays
g.push(linesS(P, [[124, -6, 124, 37]], 0.01, "iso-dash"));
var segs = [], k;
for (k = 0; k <= 10; k++) segs.push([18 + 2.77 * k, 6, 18 + 2.77 * k, 11.8]);
for (k = 0; k <= 10; k++) segs.push([20 + 1.37 * k, 26.2, 20 + 1.37 * k, 28.6]);
for (k = 0; k <= 8; k++) segs.push([14 + 4.5 * k, 20, 14 + 4.5 * k, 22.5]);
g.push(linesS(P, segs, 0.01, "iso-bayline"));
g.push(flatS(P, rect(119.25, 65.5, 122.75, 69), 0.01, "iso-hazard"));
g.push(flatS(P, rect(125.25, 65.5, 128.75, 69), 0.01, "iso-hazard"));
// buried conveying line from the silos to the colour store
g.push(linesS(P, [[43.5, 101.5, 43.5, 94]], 0.02, "iso-buried"));
// lamp light pools (visible at dusk)
LAMPS.forEach(function (p) {
var s = P.s(p[0], p[1], 0);
g.push('<ellipse class="iso-pool" cx="' + r1(s[0]) + '" cy="' + r1(s[1]) + '" rx="34" ry="17" fill="url(#iso-pool-' + sc.uid + ')"/>');
});
g.forEach(function (x) { sc.g(x); });
sc.inf("road", "Public road", "Road side assumed south of the plot, as in the Payap scenario model");
sc.inf("apron", "Truck apron and dock face", "Gate road to the two dock doors of dispatch; the apron joins the service road round the east end");
sc.inf("lane", "Service lane", "6 m lane behind the factory: utility yard, silos, water treatment and the ramp at the machine door");
sc.inf("yard", "Utility yard (detached)", "Rooms built once for four boiler and four chiller modules; modules arrive one press stage at a time (scale.md)");
sc.inf("parking", "Car park", "Roofed staff parking: 10 car bays under a 27.7 x 7.0 m canopy, ~30 scooter spaces under 13.7 x 5.5 m (construction RFQ)");
}
function fence(sc, x0, y0, x1, y1) {
var s = sc.sprite("site");
if (y0 === y1) s.box(x0, y0 - 0.08, x1, y0 + 0.08, 0, 1.8, "iso-fence");
else s.box(x0 - 0.08, y0, x0 + 0.08, y1, 0, 1.8, "iso-fence");
}
function tree(sc, x, y, r) {
var s = sc.sprite("trees"), P = sc.P, h = 1.4 + r * 0.6, c = P.s(x, y, h + r * 0.9), b = P.s(x, y, 0), t = P.s(x, y, h);
s.extBox(x - r, y - r, x + r, y + r, 0, h + r * 1.8);
s.raw('<path class="iso-trunk" d="M' + r1(b[0] - 0.7) + " " + r1(b[1]) + "L" + r1(t[0] - 0.7) + " " + r1(t[1]) + "L" + r1(t[0] + 0.7) + " " + r1(t[1]) + "L" + r1(b[0] + 0.7) + " " + r1(b[1]) + 'Z"/>');
var R = r * C * 1.18;
s.raw('<circle class="iso-leaf iso-l" cx="' + r1(c[0]) + '" cy="' + r1(c[1]) + '" r="' + r1(R) + '"/>');
s.raw('<circle class="iso-leaf iso-t" cx="' + r1(c[0] - R * 0.28) + '" cy="' + r1(c[1] - R * 0.3) + '" r="' + r1(R * 0.58) + '"/>');
}
function lamp(sc, x, y) {
var s = sc.sprite("site"), P = sc.P;
s.box(x - 0.08, y - 0.08, x + 0.08, y + 0.08, 0, 6, "iso-lampost", { noTop: true });
var p = P.s(x, y, 6.1);
s.raw('<circle class="iso-lamp" cx="' + r1(p[0]) + '" cy="' + r1(p[1]) + '" r="1.3"/>');
}
function sitePieces(sc, cfg, al) {
// plot fence with the gate opening
fence(sc, 0, 0, 117.2, 0); fence(sc, 130.8, 0, 150, 0); fence(sc, 150, 0, 150, 120);
fence(sc, 0, 120, 150, 120); fence(sc, 0, 0, 0, 120);
// gate piers, sliding leaves (open), boom barrier
sc.sprite("gate").box(117.2, 0, 118, 0.8, 0, 2.6, "iso-conc");
sc.sprite("gate").box(130, 0, 130.8, 0.8, 0, 2.6, "iso-conc");
var lv = sc.sprite("gate"); lv.box(107, 0.15, 117.1, 0.4, 0.1, 2.0, "iso-gate");
lv.open([107, 0.15], [117.1, 0.15], [[0.3, 9.8, 0.4, 0.6], [0.3, 9.8, 1.4, 1.6]], "iso-bars");
var bm = sc.sprite("gate"); bm.box(116.6, 4.2, 117.2, 4.8, 0, 1.1, "iso-steel"); bm.box(116.75, 4.35, 117.05, 4.65, 1.1, 6.6, "iso-boom");
sc.inf("gate", "Main gate", "Piers and two 5 m sliding leaves, 10 m clear; boom barrier just inside that cars and trucks both pass");
// guard house
var gh = sc.sprite("guardhouse"), S = sides(111, 1, 115, 4);
gh.box(111, 1, 115, 4, 0, 0.2, "iso-plinth").box(111, 1, 115, 4, 0.2, 3.0, "iso-office", { top: "iso-roof3" });
gh.open(S.S[0], S.S[1], [[0.6, 2.2, 1.1, 2.2]], "iso-win iso-lit").open(S.E[0], S.E[1], [[0.8, 1.8, 0.2, 2.3]], "iso-door")
.open(S.W[0], S.W[1], [[0.8, 2.2, 1.1, 2.2]], "iso-win iso-lit").open(S.N[0], S.N[1], [[1, 2.6, 1.1, 2.2]], "iso-win");
gh.box(112.2, 2.0, 113.2, 2.8, 3.0, 3.5, "iso-ac");
sc.inf("guardhouse", "Guard house, 4 x 3 m", "WC and air conditioning, at the gate's north-west side (construction RFQ)");
// fire water tank and pump house
var ft = sc.sprite("firewater"); ft.cyl(84, 21, 4.5, 0, 4.0, "iso-firetank");
ft.disc(84, 21, 4.02, 3.6, "iso-tankring");
var ph = sc.sprite("firewater"), S2 = sides(76, 18.5, 79.5, 21.5);
ph.box(76, 18.5, 79.5, 21.5, 0, 3.0, "iso-office", { top: "iso-roof3" });
ph.open(S2.S[0], S2.S[1], [[1, 2.2, 0, 2.2]], "iso-door").open(S2.E[0], S2.E[1], [[0.6, 2.4, 1.6, 2.4]], "iso-louvre");
sc.inf("firewater", "Fire water tank and pump house", "9 m x 4 m tank, ~200 m3 usable (planning figure); pumps: duty electric, standby diesel, jockey (facility-security.md)");
// lamps
LAMPS.forEach(function (p) { lamp(sc, p[0], p[1]); });
// trees
var T = [], x, y;
for (y = 8; y <= 112; y += 13) T.push([2.6, y, 1.6 + hash(y) * 0.6]);
for (x = 8; x <= 146; x += 12) T.push([x, 117.6, 1.5 + hash(x) * 0.5]);
for (y = 4; y <= 120; y += 14) T.push([154, y, 1.7 + hash(y + 3) * 0.6]);
for (x = 6; x <= 104; x += 14) T.push([x, -3, 1.4 + hash(x + 1) * 0.4]);
[136, 146, 155].forEach(function (x) { T.push([x, -3, 1.5]); });
T.push([10.5, 39, 1.5], [49.5, 40, 1.5], [8, 60, 1.8], [8, 48, 1.6], [50, 64, 1.5], [104, 20, 1.8], [110, 30, 1.6], [62, 24, 1.7], [70, 26, 1.4], [96, 15, 1.5]);
if (!al.p2show) T.push([51, 50, 1.6], [99, 40, 1.8], [99, 52, 1.6]);
T.forEach(function (t) { tree(sc, t[0], t[1], t[2]); });
sc.inf("trees", "Trees", "Schematic planting on the plot boundary");
sc.inf("site", "Site plot (schematic)", "Payap scenario layout on a placeholder plot; road assumed south (site-candidates.md)");
}
function parking(sc, cfg, pv) {
// cars and scooters are drawn inside their canopy's sprite, before the posts and the roof,
// so the roof always covers them (as separate sprites the depth sort put some above the roof)
var cols = ["iso-car1", "iso-car2", "iso-car3", "iso-car4"];
var cp = sc.sprite("canopy-cars");
[0, 1, 3, 4, 6, 8, 9].forEach(function (b, i) {
var x = 18 + 2.77 * b + 0.5;
cp.box(x, 6.6, x + 1.75, 11.2, 0.25, 1.05, cols[i % 4]).box(x + 0.15, 7.8, x + 1.6, 10.2, 1.05, 1.55, "iso-glassd");
});
[18.1, 31.85, 45.6].forEach(function (x) { cp.box(x - 0.13, 9.37, x + 0.13, 9.63, 0, 3.4, "iso-steel", { noTop: true }); });
cp.prism(rect(18, 6, 45.7, 13), [2.75, 2.75, 3.95, 3.95], [2.9, 2.9, 4.1, 4.1], "iso-canopy");
pvDraw(cp, pv.by.cars.F, pv.by.cars.panels);
var sp = sc.sprite("canopy-scooters");
for (var k = 0; k < 7; k++) sp.box(20.4 + 1.37 * k * 1.4, 26.9, 20.8 + 1.37 * k * 1.4, 28.1, 0.2, 1.0, cols[(k + 1) % 4]);
[20.1, 33.6].forEach(function (x) { sp.box(x - 0.12, 28.6, x + 0.12, 28.85, 0, 2.9, "iso-steel", { noTop: true }); });
sp.prism(rect(20, 26, 33.7, 31.5), [2.3, 2.3, 3.35, 3.35], [2.45, 2.45, 3.5, 3.5], "iso-canopy");
pvDraw(sp, pv.by.scoot.F, pv.by.scoot.panels);
var nc = pv.by.cars.panels.length, ns = pv.by.scoot.panels.length;
sc.inf("canopy-cars", "Car canopy, 10 bays", "27.7 x 7.0 m at 4.1 m (construction RFQ)" + (nc ? "; " + nc + " PV modules (~" + fmt(nc * PV_MOD) + " kWp)" : "; PV-ready: steel sized for ~42 kWp of panels, conduit to the main board; panels later"));
sc.inf("canopy-scooters", "Scooter canopy, ~30 spaces", "13.7 x 5.5 m at 3.5 m (construction RFQ)" + (ns ? "; " + ns + " PV modules (~" + fmt(ns * PV_MOD) + " kWp)" : "; PV-ready: steel sized for ~13 kWp of panels, conduit to the main board; panels later"));
}
// ------------------------------------------------------------ factory
// finishing and print block, 14 x 25 m on two levels at fx 92..106 (site-candidates.md, block floor plans):
// ground floor cooling racks + optical inspection, QC lab, stair + goods lift along the corridor, packaging behind; upper floor at +3.8 m
var BLK = { fx0: 92, fx1: 106, up: 3.8 };
function zoneList(cfg, up) {
var z = [
[0, 0, 8, 10, "iso-z-staff", "staffentry"],
[4.6, 3.6, 8, 5.8, "iso-z-wc", "wc-w"], [4.6, 5.8, 8, 7.9, "iso-z-wc", "wc-w"], [4.6, 7.9, 8, 10, "iso-z-wc", "wc-w"],
[0, 10, 16, 25, "iso-z-shop", "workshop"],
[8, 2.6, 16, 10, "iso-z-shop2", "workshop"],
[16, 2.6, 23, 20.5, "iso-z-galv", "galvanics"],
[23, 2.6, 30, 11.5, "iso-z-lab", "pvclab"],
[23, 11.5, 30, 20.5, "iso-z-prep", "stamperprep"],
[30, 2.6, 40, 20.5, "iso-z-colour", "colourstore"],
[40, 2.6, 92, 20.5, "iso-z-press", "presshall"],
[40, 12.4, 47.8, 20.5, "iso-z-plant", "plantroom"],
[16, 20.5, up ? 92 : 106, 25, "iso-z-aisle", "aisle"],
[106, 0, 120, 25, "iso-z-wh", "warehouse"],
[120, 0, 128, 25, "iso-z-disp", "dispatch"],
[8, 0, up ? 92 : 106, 2.6, cfg.corridor ? "iso-z-corr" : "iso-z-floor", "corridor"]
];
if (!up) z.push([92, 2.6, 96, 7.9, "iso-z-trim", "trim"], [96, 2.6, 100, 7.9, "iso-z-qc", "qc"], [100, 2.6, 106, 7.9, "iso-z-core", "block-stair"],
[92, 7.9, 106, 20.5, "iso-z-pack", "packaging"], [100, 20.5, 103, 24.7, "iso-z-wc", "wc-e"], [103, 20.5, 106, 24.7, "iso-z-wc", "wc-e"]);
return z;
}
function factoryInfo(sc, cfg, al) {
sc.inf("factory", "Factory, 128 x 25 m",
"Production floor only (Payap scenario model): workshop, galvanics, stamper prep, PVC lab, colour store, press hall, finishing block; warehouse and dispatch behind the fire wall. Monopitch roof, low eave 7.0 m, high side 11.4 m.");
sc.inf("presshall", "Press hall, ~870 m2",
al.Dh + " Duplex system" + (al.Dh === 1 ? "" : "s") + " and " + al.Ch + " Classic" + (al.Ch === 1 ? "" : "s") + " in the hall; laid out for special pressings: 5 Duplex bays and 4 Classic positions, ~750 m2, balanced by output at 60 % specials (pressing-line.md)" +
(al.p2need ? "; " + (al.ovD + al.ovC) + " more in the phase 2 building" : ""));
sc.inf("galvanics", cfg.galvanics ? "Galvanics clean room (Pheenix Alpha line)" : "Galvanics room: stampers bought in",
cfg.galvanics ? "PASU, PPPU, PDPU, PMPB; one PDPU covers full-capacity demand, a second is redundancy (galvanics.md). Room-in-room under the crane, open to stamper prep." :
"No electroforming on site: stampers are bought in, and the water-treatment building is not built.");
sc.inf("stamperprep", cfg.galvanics ? "Stamper prep: Sibert VSF, VSP, VSC" : "Stamper prep: stampers bought in",
cfg.galvanics ? "Line-up 4958 x 833 mm, operation order VSF back-finish, VSP punch, VSC coin (galvanics.md); stamper metrology bench." : "Bought stampers arrive press-ready; the room holds the stamper rack.");
sc.inf("pvclab", "PVC lab, regrind and conveying", "Compound lot checks before release, granulator and vacuum conveying (quality-automation.md tiers)");
sc.inf("colourstore", "Colour store, ~180 m2", "About 100 pallet positions on four levels for tens of colours (estimate); weighing, mixing and vacuum conveying");
sc.inf("plantroom", "Central hydraulic and air plant room, ~62 m2", "Pump sets on one manifold with the accumulators, one compressed-air plant; ring mains with a valved tee at every press position (pressing-line.md)");
sc.inf("aisle", "Crane and forklift aisle, 4.5 m", "Along the back wall from the machine door to the finishing block: heavy moves need a floor-level path");
sc.inf("trim", "Optical inspection after cooling", "One station checks every record of both presses after the cooling racks: surface (a line-scan camera per side), label, warp and centring in one turn. The finishing operator feeds it from the cooled stacks; good records go on to sleeving, rejects into the bin with the reason on the screen. Early lane: the first records of each run and after each stamper change go through warm. 1.9 x 0.65 x 1.5 m, about 10 to 12 records a minute against 5 to 6 from the launch plant; hardware about USD 17k to 33k (estimate) (inline-optical-qc.md). No separate trimmer: every Duplex cell and the Classic trim inline (pressing-line.md)");
sc.inf("cooling", "Cooling racks", "Spindle stacks from both presses, labelled at the press (press, stamper set, first and last cycle), first in, first out. 24 h of cooling before sleeving is accepted practice, 8 h at the shortest (Stamper Discs, in inline-optical-qc.md): about one day of output, 2,800 to 3,100 records at the launch plant");
sc.inf("qc", "QC and listening", "Sampled audio comparison (about 1 in 50) and the listening point on the tour (quality-automation.md)");
sc.inf("packaging", "Finishing and packaging", "Packing tables, shrink wrap and pallet wrapper, ~170 m2 on the ground floor of the finishing and print block (14 x 25 m on two levels, print room reserved upstairs)");
sc.inf("block-stair", "Stair and goods lift", "Connect the two levels of the finishing and print block. The crane runway ends at the press hall, so loads here move by forklift and goods lift; heavy compound stays on the ground floor (site-candidates.md, crane-workshop-tours.md)");
if (cfg.cassette) sc.inf("cassette", "Cassette duplication", "Bench line, ~40 to 80 m2 (project-timeline.md)");
if (cfg.cdr) sc.inf("cdr", "CD-R duplication, short run", "Tower duplicator and printer bench, ~20 to 40 m2 (project-timeline.md)");
sc.inf("printblock", "Finishing and print block, 14 x 25 m",
"Two levels: cooling racks, optical inspection, the QC lab and packaging (~170 m2) on the ground floor; print room (reserve), print stock and packaging materials on the upper floor at +3.8 m (site-candidates.md)");
sc.inf("printroom", "Print room (kept in reserve)", "Upper floor at +3.8 m. In-house printing is not decided; jackets come from a print partner today (site-candidates.md, production-timeline.md)");
sc.inf("printstock", "Print stock", "Paper and board, upper floor at +3.8 m (site-candidates.md)");
sc.inf("packmat", "Packaging materials", "Sleeves, inner bags, mailers and cartons, upper floor at +3.8 m (site-candidates.md)");
sc.inf("warehouse", "Warehouse behind the fire wall", "Three racking rows; fire wall EI 60/120; forklift territory, no crane");
sc.inf("dispatch", "Dispatch, two dock doors", "Pallets staged for the trucks on the apron");
sc.inf("workshop", "Workshop and machine bay, ~200 m2", "Service bay, benches, spares, 3D printers, a lathe and a small CNC mill; machine door 4.5 x 5.0 m with the ramp; the crane parks here (crane-workshop-tours.md)");
sc.inf("staffentry", "Staff entry (PPE)", "Staff change in the office building, cross by the covered link and turn in here from the start of the visitor corridor (site-candidates.md)");
sc.inf("wc-w", "Toilets at the staff entry", "Women: 2 WC and a basin; men: 1 WC, 1 urinal and a basin; an accessible WC. Thai minimum for a factory, per up to 15 workers of each sex (Ministerial Regulation No. 39, B.E. 2537, table 2); separate toilets for women, men and disabled staff (labour welfare regulation B.E. 2548). Showers are in the office changing rooms.");
sc.inf("wc-e", "Toilets, finishing block", "Second set at the east end of the press hall, so nobody walks ~90 m back to the staff entry: women 2 WC and a basin, men 1 WC, 1 urinal and a basin. Proposed (October 2026), not yet in the parametric floor plans.");
sc.inf("corridor", cfg.corridor ? "Visitor corridor, glazed, ~98 m" : "Visitor corridor: later",
cfg.corridor ? "Along the car-park wall in process order: cutting, galvanics, stamper prep, material lab, press hall, cooling racks and optical inspection, QC and listening, finishing. No PPE, filtered air." :
"The strip along the car-park wall stays free; the glazed corridor is built later.");
sc.inf("crane", cfg.crane === "buy" ? "Overhead crane, 5 t" : "Crane-ready frame (crane rented)",
cfg.crane === "buy" ? "EOT crane over the production compartment, runway to the end of the press hall, rail at ~5.6 m; parks over the workshop (crane-workshop-tours.md)" :
"Runway brackets and rails in the frame; a crane is rented for installations");
sc.inf("conveyor", "Records to finishing", "Schematic: records travel from the presses to the cooling racks (in practice as spindle stacks)");
sc.inf("pipebridge", "Pipe bridge", "Steam, condensate and cooling water from the yard to the service trench along the press rows");
}
function factoryRoof(sc, cfg, pv) {
var x0 = X(0), y0 = Y(0), x1 = X(FL), y1 = Y(FW), zS = roofZ(0), zN = roofZ(FW), P = sc.P;
var s = sc.sprite("factory");
s.box(x0, y0, x1, y1, 0, FFL, "iso-plinth");
s.prism(rect(x0, y0, x1, y1), FFL, [zS, zS, zN, zN], "iso-wall", { top: "iso-roof" });
var S = sides(x0, y0, x1, y1), win = [], lit = [], i, u;
for (u = 10, i = 0; u < 90; u += 4, i++) (hash(i) > 0.3 ? lit : win).push([u, u + 2.6, FFL + 4.4, FFL + 5.6]);
[93, 96.5, 100, 103].forEach(function (u, k) { (k % 2 ? win : lit).push([u, u + 2.4, FFL + 4.4, FFL + 5.6]); });
lit.push([5.8, 7.4, FFL + 1, FFL + 2.2], [123, 126, FFL + 1, FFL + 2.2]);
s.open(S.S[0], S.S[1], win, "iso-win").open(S.S[0], S.S[1], lit, "iso-win iso-lit");
if (cfg.corridor) s.open(S.S[0], S.S[1], [[10.3, 105.7, FFL + 0.4, FFL + 2.7]], "iso-glass", "corridor");
else {
var cw = []; for (u = 11; u < 104; u += 6) cw.push([u, u + 2, FFL + 1, FFL + 2.2]);
s.open(S.S[0], S.S[1], cw, "iso-win");
}
// entrance door centred on the covered link (staff turn west to the staff entry, visitors east along the corridor)
s.open(S.S[0], S.S[1], [[LINK.x0 + 0.7 - FX, LINK.x1 - 0.7 - FX, FFL, FFL + 2.5], [107, 108.2, FFL, FFL + 2.2]], "iso-door", "link");
s.open(S.S[0], S.S[1], [[111.25, 114.75, FFL, FFL + 4.0], [117.25, 120.75, FFL, FFL + 4.0]], "iso-dock", "dispatch");
s.open(S.E[0], S.E[1], [[3, 6, FFL + 1, FFL + 2.3], [8, 11, FFL + 1, FFL + 2.3]], "iso-win iso-lit")
.open(S.E[0], S.E[1], [[14, 16, FFL, FFL + 2.5]], "iso-door")
.open(S.E[0], S.E[1], [[4, 7, FFL + 5, FFL + 6], [10, 13, FFL + 5.3, FFL + 6.3], [17, 20, FFL + 5.6, FFL + 6.6]], "iso-win");
var nw = [], nl = [];
for (u = 36, i = 0; u < 118; u += 4, i++) (hash(i + 40) > 0.4 ? nl : nw).push([u, u + 2.6, FFL + 5.6, FFL + 6.8]);
s.open(S.N[0], S.N[1], nw, "iso-win").open(S.N[0], S.N[1], nl, "iso-win iso-lit")
.open(S.N[0], S.N[1], [[121.5, 126, FFL, FFL + 5.0]], "iso-door2", "workshop")
.open(S.N[0], S.N[1], [[108, 110, FFL, FFL + 2.6]], "iso-door", "galvanics");
s.open(S.W[0], S.W[1], [[3, 6, FFL + 1.2, FFL + 2.6], [8, 11, FFL + 1.2, FFL + 2.6], [16, 19, FFL + 1.2, FFL + 2.6]], "iso-win iso-lit")
.open(S.W[0], S.W[1], [[5, 8, FFL + 6, FFL + 7.2], [13, 16, FFL + 5.6, FFL + 6.8]], "iso-win");
// roof: PV fields either side of the fire wall parapet, vents
var fwX0 = X(105.8), fwX1 = X(106.2);
var parapet = function (q) {
q.prism(rect(fwX0, y0, fwX1, y1), [zS, zS, zN, zN], [zS + 0.5, zS + 0.5, zN + 0.5, zN + 0.5], "iso-firewall");
};
var W = pv.by.roofW, E = pv.by.roofE, seams = "", fxs;
for (fxs = 8; fxs < FL; fxs += 8) {
if (Math.abs(fxs - 106) < 1) continue;
seams += P.d([[X(fxs), y0, zS + 0.02], [X(fxs), y1, zN + 0.02]], true);
}
s.raw('<path class="iso-seam" d="' + seams + P.d([[x0, Y(FW / 2), roofZ(FW / 2) + 0.02], [x1, Y(FW / 2), roofZ(FW / 2) + 0.02]], true) + '"/>');
var vents = function (q) {
[48, 64, 80].forEach(function (fx) {
var zr = roofZ(18.5);
q.cyl(X(fx), Y(18.5), 0.45, zr - 0.2, zr + 1.0, "iso-steel");
q.disc(X(fx), Y(18.5), zr + 1.0, 0.3, "iso-ventc");
if (cfg.running) q.puffs(X(fx), Y(18.5), zr + 1.1, "iso-puff2", 3, 4.2);
});
};
s.seq([
[X(52), Y(12), function (q) { pvDraw(q, W.F, W.panels); vents(q); }],
[X(106), Y(12), parapet],
[X(117), Y(12), function (q) { pvDraw(q, E.F, E.panels); }]
]);
// pipe bridges and the covered crossing to water treatment are separate sprites
}
function factoryInside(sc, cfg, al, pv, up) {
var x0 = X(0), y0 = Y(0), x1 = X(FL), y1 = Y(FW), P = sc.P, z = FFL, xe = up ? X(BLK.fx0) : X(100);
var fl = sc.sprite("factory");
fl.box(x0, y0, x1, y1, 0, FFL, "iso-plinth");
var zones = zoneList(cfg, up);
zones.forEach(function (q) { fl.flat(rect(X(q[0]), Y(q[1]), X(q[2]), Y(q[3])), z + 0.005, q[4] + " iso-t", q[5]); });
var segs = [];
zones.forEach(function (q) {
if (q[5] === "presshall" || q[5] === "aisle") return;
var a = X(q[0]), b = Y(q[1]), c = X(q[2]), d = Y(q[3]);
segs.push([a, b, c, b], [c, b, c, d], [c, d, a, d], [a, d, a, b]);
});
fl.lines(segs, z + 0.01, "iso-part");
fl.lines([[X(16), Y(20.5), xe, Y(20.5)], [X(16), Y(24.6), xe, Y(24.6)]], z + 0.01, "iso-mark");
// service trench along the press rows
fl.flat(rect(X(40), Y(11.6), X(92), Y(12.4)), z + 0.01, "iso-trench");
var gr = []; for (var gx = 40.5; gx < 92; gx += 1.2) gr.push([X(gx), Y(11.65), X(gx), Y(12.35)]);
fl.lines(gr, z + 0.012, "iso-grate");
// slot outlines: hall and the empty positions carry their own ids
var eD = 0, eC = 0;
al.hall.forEach(function (sl) {
var pts = rect(sl.x0 + 0.25, sl.y0 + 0.25, sl.x1 - 0.25, sl.y1 - 0.25);
if (sl.e) {
var id = sl.t === "D" ? "pos-d" + (++eD) : "pos-c" + (++eC);
fl.flat(pts, z + 0.015, "iso-slot iso-empty", id);
sc.inf(id, sl.t === "D" ? "Duplex bay (room reserved)" : "Classic position (reserved)",
sl.t === "D" ? "Growth option: room, utility modules and ring-main tees reserved, not in the investment (pressing-line.md)" :
"Classic position kept for growth (option, not in the investment); the hall is laid out mainly for Duplex systems (pressing-line.md)");
} else fl.flat(pts, z + 0.015, "iso-slot", sl.t === "D" ? "duplex-" + sl.n : "classic-" + sl.n);
if (!sl.e && sl.t === "D") {
var cx = (sl.x0 + sl.x1) / 2, cy = (sl.y0 + sl.y1) / 2;
fl.flat(rect(cx - 2.6, cy - 2.0, cx + 2.6, cy + 2.15), z + 0.02, "iso-cell", "duplex-" + sl.n);
}
});
// walls cut: back walls taller than front walls (cutaway)
wallsCut(sc, "factory", x0, y0, x1, y1, z, 1.0, 3.0, 0.3, "iso-wall");
sc.sprite("factory").box(X(105.85), Y(0.3), X(106.15), Y(FW - 0.3), z, z + 2.2, "iso-firewall", { top: "iso-cut" });
if (cfg.corridor) sc.sprite("corridor").box(X(8), Y(2.55), up ? X(BLK.fx0) : X(106), Y(2.65), z, z + 2.6, "iso-glassw");
// frame columns and crane runway (production compartment, west wall to the end of the press hall)
[[0.3, 0.62], [24.38, 24.7]].forEach(function (yy) {
var s = sc.sprite("crane", P.vis(0, yy[0] < 12 ? -1 : 1) ? "iso-ghost" : "");
var cols = [];
for (var fx = 0.5; fx <= 92; fx += 8) cols.push(fx);
cols.push(91.6);
s.seq(cols.map(function (fx) { return [X(fx), Y(yy[0]), function (q) { q.box(X(fx) - 0.16, Y(yy[0]), X(fx) + 0.16, Y(yy[1]), z, z + 5.6, "iso-col", { noTop: true }); }]; }));
s.box(X(0.3), Y(yy[0]), X(92), Y(yy[1]), z + 5.6, z + 5.95, "iso-rail");
});
if (cfg.crane === "buy") craneM(sc, z, cfg);
else sc.tag(X(46), Y(24.5), z + 6.5, "Crane-ready (rented)", 5, "crane");
machinesFactory(sc, cfg, al, z, up);
// presses in the hall
al.hall.forEach(function (sl) {
if (sl.e) return;
var cx = (sl.x0 + sl.x1) / 2, cy = (sl.y0 + sl.y1) / 2;
if (sl.t === "D") { duplexCell(sc, sl.n, cx, cy, z, cfg.running); sc.tag(cx, cy, z + 3.2, "Duplex " + sl.n, 8, "duplex-" + sl.n); }
else { classicM(sc, sl.n, cx, cy, z, cfg.running); sc.tag(cx, cy, z + 3.2, "Classic " + sl.n, 6, "classic-" + sl.n); }
});
// record path to finishing (schematic conveyor along the corridor glass)
conveyor(sc, z, cfg, up);
if (cfg.corridor && !up) visitors(sc, z, cfg);
}
function wallsCut(sc, id, x0, y0, x1, y1, z0, lo, hi, t, mat) {
var P = sc.P;
[["S", x0, y0, x1, y0 + t, 0, -1], ["N", x0, y1 - t, x1, y1, 0, 1], ["W", x0, y0 + t, x0 + t, y1 - t, -1, 0], ["E", x1 - t, y0 + t, x1, y1 - t, 1, 0]].forEach(function (w) {
var front = !!P.vis(w[5], w[6]);
sc.sprite(id).box(w[1], w[2], w[3], w[4], z0, z0 + (front ? lo : hi), mat, { top: "iso-cut" });
});
}
function craneM(sc, z, cfg) {
var P = sc.P, bx = X(6), travel = 56, v = P.dv(travel, 0, 0), st = "--dx:" + r1(v[0]) + "px;--dy:" + r1(v[1]) + "px";
var s = sc.sprite("crane");
s.extBox(bx - 0.9, Y(0.3), bx + travel + 0.9, Y(24.7), z + 5.95, z + 7.3);
s.ga("iso-a iso-crane", st);
s.seq([
[bx, Y(0.6), function (q) { q.box(bx - 0.9, Y(0.3), bx + 0.9, Y(0.95), z + 5.95, z + 6.45, "iso-crane"); }],
[bx, Y(24.4), function (q) { q.box(bx - 0.9, Y(24.05), bx + 0.9, Y(24.7), z + 5.95, z + 6.45, "iso-crane"); }]
]);
s.box(bx - 0.3, Y(0.5), bx + 0.3, Y(24.5), z + 6.0, z + 6.65, "iso-crane");
s.box(bx - 0.6, Y(11.5), bx + 0.6, Y(12.7), z + 6.65, z + 7.25, "iso-hoist");
s.ge();
var hk = sc.sprite("crane");
hk.extBox(bx - 0.25, Y(11.85), bx + travel + 0.25, Y(12.35), z + 5.0, z + 5.95);
hk.ga("iso-a iso-crane", st);
var a = P.s(bx, Y(12.1), z + 6.0), b = P.s(bx, Y(12.1), z + 5.4);
hk.raw('<path class="iso-rope" d="M' + r1(a[0]) + " " + r1(a[1]) + "L" + r1(b[0]) + " " + r1(b[1]) + '"/>');
hk.box(bx - 0.22, Y(11.88), bx + 0.22, Y(12.32), z + 5.0, z + 5.4, "iso-hook");
hk.ge();
sc.tag(X(30), Y(12), z + 7.6, "Crane 5 t", 6, "crane");
}
function conveyor(sc, z, cfg, up) {
var P = sc.P, s = sc.sprite("conveyor"), xa = X(40.6), xb = up ? X(BLK.fx0 - 0.2) : X(93), y0 = Y(2.9), y1 = Y(3.35);
s.box(xa, y0, xb, y1, z + 0.72, z + 0.85, "iso-belt");
if (!cfg.running) return;
var L = xb - xa - 1, v = P.dv(L, 0, 0), n = 8, dur = 16, i, p = P.s(xa + 0.5, (y0 + y1) / 2, z + 0.88), q = P.s(xa + 0.5, (y0 + y1) / 2, z + 0.88);
var rx = 0.26 * C * SQ2, lab = ["iso-lab1", "iso-lab2", "iso-lab3", "iso-lab4"];
for (i = 0; i < n; i++) {
s.raw('<g class="iso-a iso-disc" style="--dx:' + r1(v[0]) + "px;--dy:" + r1(v[1]) + "px;animation-delay:-" + (i * dur / n).toFixed(2) + 's">' +
'<ellipse class="iso-vinylc" cx="' + r1(p[0]) + '" cy="' + r1(p[1]) + '" rx="' + r1(rx) + '" ry="' + r1(rx / 2) + '"/>' +
'<ellipse class="' + lab[i % 4] + '" cx="' + r1(q[0]) + '" cy="' + r1(q[1]) + '" rx="' + r1(rx * 0.38) + '" ry="' + r1(rx * 0.19) + '"/></g>');
}
}
function visitors(sc, z, cfg) {
var P = sc.P, s = sc.sprite("corridor"), xa = X(12), travel = 86, v = P.dv(travel, 0, 0);
s.extBox(xa - 1, Y(0.75), xa + travel + 3, Y(2.4), z, z + 1.7);
s.ga("iso-a iso-walk", "--dx:" + r1(v[0]) + "px;--dy:" + r1(v[1]) + "px");
s.seq([[xa + 2.2, Y(1.2), function (q) { q.person(xa + 2.2, Y(1.2), z, "iso-guide"); }],
[xa, Y(1.0), function (q) { q.person(xa, Y(1.0), z, "iso-visitor"); }],
[xa + 0.9, Y(1.9), function (q) { q.person(xa + 0.9, Y(1.9), z, "iso-visitor2"); }],
[xa - 0.6, Y(1.7), function (q) { q.person(xa - 0.6, Y(1.7), z, "iso-visitor"); }]]);
s.ge();
sc.tag(X(20), Y(1.3), z + 2.4, "Visitor corridor", 6, "corridor");
}
function rackRow(sc, id, x0, y0, x1, y1, z, h, levels, bays, palette, mat) {
var s = sc.sprite(id), S = sides(x0, y0, x1, y1), groups = {};
s.box(x0, y0, x1, y1, z, z + h, mat || "iso-rackf");
["S", "E", "N", "W"].forEach(function (k) {
var A = S[k][0], B = S[k][1], L = Math.hypot(B[0] - A[0], B[1] - A[1]), nb = Math.max(1, Math.round(L / (L > 3 ? 1.35 : 1.1))), bw = L / nb, lv, b, cls;
for (lv = 0; lv < levels; lv++) for (b = 0; b < nb; b++) {
cls = palette[Math.floor(hash(lv * 31 + b * 7 + x0 + y0 + k.charCodeAt(0)) * palette.length)];
if (!cls) continue;
(groups[k + cls] = groups[k + cls] || { A: A, B: B, cls: cls, l: [] }).l.push([b * bw + 0.08, (b + 1) * bw - 0.08, z + 0.15 + lv * h / levels, z + (lv + 0.8) * h / levels]);
}
});
Object.keys(groups).forEach(function (k) { var g = groups[k]; s.open(g.A, g.B, g.l, g.cls); });
return s;
}
function machinesFactory(sc, cfg, al, z, up) {
var P = sc.P, i;
// staff entry: lockers
sc.sprite("staffentry").box(X(0.6), Y(2), X(1.1), Y(8.5), z, z + 1.9, "iso-locker");
// spares store (fx 8..16, fy 2.6..10): shelving
rackRow(sc, "workshop", X(9), Y(4.2), X(15.5), Y(4.8), z, 2.3, 4, 0, ["iso-pal-bx", "iso-pal-w", "", "iso-pal-bx"], "iso-shelf");
rackRow(sc, "workshop", X(9), Y(7.4), X(15.5), Y(8.0), z, 2.3, 4, 0, ["iso-pal-bx", "", "iso-pal-w", "iso-pal-bx"], "iso-shelf");
// workshop: service bay with a press on it, benches, 3D printers, lathe, CNC mill
var fl = sc.sprite("workshop");
fl.flat(rect(X(2), Y(14), X(9), Y(22)), z + 0.02, "iso-bay");
pressM(sc, "workshop", X(5.5), Y(18), z + 0.02, false, false);
sc.sprite("workshop").box(X(0.5), Y(11), X(1.3), Y(16), z, z + 0.9, "iso-bench");
sc.sprite("workshop").box(X(0.5), Y(17.5), X(1.3), Y(22.5), z, z + 0.9, "iso-bench");
sc.sprite("workshop").seq([0, 1, 2].map(function (k) { return [X(11 + k * 1.3), Y(11.5), function (q) { q.box(X(10.6 + k * 1.3), Y(11), X(11.5 + k * 1.3), Y(11.9), z, z + 1.1, "iso-printer"); }]; }));
sc.sprite("workshop").box(X(11), Y(16.8), X(13.6), Y(17.7), z, z + 1.2, "iso-steel");
sc.sprite("workshop").box(X(12.4), Y(21.2), X(14.3), Y(23.2), z, z + 2.0, "iso-mill");
sc.sprite("workshop").cyl(X(10.2), Y(22.3), 0.3, z, z + 1.7, "iso-steel");
sc.tag(X(6), Y(16), z + 3.4, "Workshop", 6, "workshop");
sc.tag(X(2.6), Y(5), z + 2.6, "Staff entry", 2, "staffentry");
// toilets at the staff entry (fx 4.6..8): accessible WC, women (2 WC), men (WC + urinal); doors to the lobby on the west
sanitary(sc, "wc-w", z, [[7.6, 4.3, 0.28, 0.2], [7.6, 6.3, 0.28, 0.2], [7.6, 7.4, 0.28, 0.2], [7.6, 8.4, 0.28, 0.2]],
[[6.5, 6.82, 8, 6.88], [6.5, 8.92, 8, 8.98]], [[7.78, 9.3, 7.95, 9.66]],
[[7.5, 5.05, 7.95, 5.55], [4.75, 7.2, 5.2, 7.7], [4.75, 9.3, 5.2, 9.8]]);
sc.tag(X(6.3), Y(6.8), z + 2.6, "WC", 2, "wc-w");
// inner door from the corridor start into the staff entry, and the entrance mat under the covered link
sc.sprite("staffentry").flat(rect(X(7.9), Y(0.5), X(8.1), Y(2.1)), z + 0.02, "iso-door");
sc.sprite("link").flat(rect(LINK.x0 + 0.7, Y(0.3), LINK.x1 - 0.7, Y(1.1)), z + 0.02, "iso-door");
// galvanics (fx 16..23)
if (cfg.galvanics) {
var gx = X(16.45);
var u = sc.sprite("pasu"); u.box(gx, Y(3.4), gx + 0.72, Y(5.59), z, z + 0.9, "iso-galv"); u.box(gx + 0.05, Y(4.7), gx + 0.67, Y(5.5), z + 0.9, z + 1.84, "iso-galv2");
u.disc(gx + 0.36, Y(3.95), z + 0.92, 0.25, "iso-sink");
var p1 = sc.sprite("pppu"); p1.box(gx, Y(6.4), gx + 1.25, Y(7.46), z, z + 1.1, "iso-galv", { top: "iso-bath" }); p1.box(gx, Y(7.1), gx + 0.3, Y(7.46), z + 1.1, z + 1.75, "iso-galv2");
var p2 = sc.sprite("pdpu"); p2.box(gx, Y(8.3), gx + 1.35, Y(9.37), z, z + 1.1, "iso-galv", { top: "iso-bath" });
p2.disc(gx + 0.45, Y(8.84), z + 1.12, 0.22, "iso-cathode"); p2.disc(gx + 0.95, Y(8.84), z + 1.12, 0.22, "iso-cathode");
p2.box(gx + 1.05, Y(8.3), gx + 1.35, Y(9.37), z + 1.1, z + 1.8, "iso-galv2"); p2.led(gx + 1.2, Y(8.84), z + 1.82, "iso-ledg");
var pm = sc.sprite("pmpb"); pm.box(gx, Y(10.3), gx + 0.78, Y(11.89), z, z + 0.92, "iso-galv"); pm.box(gx, Y(11.3), gx + 0.78, Y(11.89), z + 0.92, z + 1.7, "iso-galv2");
pm.disc(gx + 0.39, Y(10.8), z + 0.94, 0.25, "iso-sink");
sc.sprite("pdpu2").flat(rect(X(21), Y(8.3), X(22.35), Y(9.4)), z + 0.02, "iso-slot iso-empty", "pdpu2");
var cs = sc.sprite("galvanics"); cs.seq([0, 1, 2, 3].map(function (k) { return [X(17 + k * 0.9), Y(18.5), function (q) { q.cyl(X(17 + k * 0.9), Y(18.5), 0.32, z, z + 0.9, k % 2 ? "iso-drum2" : "iso-drum"); }]; }));
sc.sprite("galvanics").box(X(20.5), Y(17.4), X(22.6), Y(17.9), z, z + 1.8, "iso-shelf");
sc.inf("pasu", "Pheenix PASU, automatic silvering unit", "Cleaning station plus silver spray unit, 2188 x 715 x 1835 mm (Pheenix flyer)");
sc.inf("pppu", "Pheenix PPPU, pre-plating unit", "Up to four masters at once, 385 L tank, 1062 x 1245 x 1750 mm (Pheenix flyer)");
sc.inf("pdpu", "Pheenix PDPU, dual plating unit", "Two cells, up to 15 VDC and 200 A per cell, 1070 x 1350 x 1800 mm; one PDPU covers full-capacity demand (galvanics.md)");
sc.inf("pmpb", "Pheenix PMPB, modular preparation bench", "Passivation, cleaning and drying between generations, 1588 x 775 x 1700 mm (Pheenix flyer)");
sc.inf("pdpu2", "Second PDPU position", "Redundancy rather than capacity; not in the launch line (galvanics.md)");
sc.tag(X(17.2), Y(7), z + 2.6, "PASU PPPU PDPU PMPB", 5, "galvanics");
// stamper prep: Sibert line along y, metrology bench
var sx = X(28.8);
sc.sprite("vsf").box(sx, Y(13.0), sx + 0.83, Y(13.98), z, z + 1.5, "iso-sibert");
sc.sprite("vsp").box(sx, Y(14.48), sx + 0.83, Y(15.46), z, z + 1.6, "iso-sibert");
var vc = sc.sprite("vsc"); vc.box(sx, Y(15.96), sx + 0.83, Y(17.96), z, z + 1.6, "iso-sibert"); vc.led(sx + 0.42, Y(16.96), z + 1.62, "iso-ledg");
sc.inf("vsf", "Sibert VSF, stamper finisher", "Automatic wet back-finishing with stamper cleaning; 250 kg (Sibert spec)");
sc.inf("vsp", "Sibert VSP, stamper punch", "Optical centring on the groove, punches the centre hole; 200 kg (Sibert spec)");
sc.inf("vsc", "Sibert VSC, stamper coiner", "Forms ID and OD automatically; 1000 kg, ~2.0 x 0.8 x 1.6 m, a crane or forklift lift (Sibert spec)");
sc.tag(sx + 0.4, Y(15.5), z + 2.6, "Sibert VSF VSP VSC", 6, "stamperprep");
var mb = sc.sprite("metrology"); mb.box(X(23.4), Y(12.6), X(24.25), Y(17.6), z, z + 0.85, "iso-bench");
mb.box(X(23.55), Y(12.9), X(24.1), Y(13.6), z + 0.85, z + 1.3, "iso-instr");
var full = cfg.metrology === "full";
mb.seq([[X(23.8), Y(14.6), function (q) { if (full) { q.box(X(23.55), Y(14.2), X(24.1), Y(15.0), z + 0.85, z + 1.5, "iso-instr2"); } else q.flat(rect(X(23.55), Y(14.2), X(24.1), Y(15.0)), z + 0.86, "iso-later"); }],
[X(23.8), Y(16.3), function (q) { if (full) { q.box(X(23.55), Y(15.8), X(24.1), Y(16.9), z + 0.85, z + 1.35, "iso-instr"); } else q.flat(rect(X(23.55), Y(15.8), X(24.1), Y(16.9)), z + 0.86, "iso-later"); }]]);
sc.inf("metrology", full ? "Stamper metrology, full stack" : "Stamper metrology, launch tier",
full ? "Laser confocal (Keyence VK-X class), wide-area profiler (VR-6000 class) and XRF thickness gauge; inline vision and the audio station on the record side (quality-automation.md)" :
"XRF thickness gauge, inline vision and the audio station; laser confocal and wide-area profiler later, bought from revenue (total-investment.md)");
sc.tag(X(23.8), Y(15), z + 2.2, full ? "Metrology" : "Metrology (launch)", 4, "metrology");
} else {
var sg = sc.sprite("galvanics"); sg.box(X(19), Y(9.6), X(19.15), Y(11.4), z, z + 1.0, "iso-steel");
sg.box(X(18.95), Y(9.5), X(19.2), Y(11.5), z + 1.0, z + 2.0, "iso-sign");
sc.tag(X(19.1), Y(10.5), z + 2.6, "Stampers bought in", 100, "galvanics", "iso-signtag");
rackRow(sc, "stamperprep", X(28.8), Y(13), X(29.5), Y(15.5), z, 1.6, 3, 0, ["iso-pal-k", "", "iso-pal-k"], "iso-shelf");
}
// PVC lab: bench, fume cupboard, granulator, conveying station
sc.sprite("pvclab").box(X(23.3), Y(3.0), X(24.1), Y(10.5), z, z + 0.9, "iso-bench");
sc.sprite("pvclab").box(X(25.8), Y(10.4), X(27.4), Y(11.2), z, z + 2.2, "iso-instr");
var gn = sc.sprite("pvclab"); gn.box(X(27.8), Y(3.8), X(29.0), Y(5.0), z, z + 1.3, "iso-granul"); gn.frust(X(28.4), Y(4.4), 0.35, 0.55, z + 1.3, z + 1.8, "iso-granul", 8);
sc.sprite("pvclab").cyl(X(28.6), Y(8.0), 0.55, z, z + 2.4, "iso-hopper");
sc.tag(X(26), Y(7), z + 2.8, "PVC lab", 4, "pvclab");
// colour store racking
var pal = ["iso-pal-k", "iso-pal-w", "iso-pal-r", "iso-pal-y", "iso-pal-b", "iso-pal-g", "iso-pal-p", "iso-pal-o", "iso-pal-k", ""];
[[30.5, 31.6], [34.2, 35.9], [38.4, 39.5]].forEach(function (xx) { rackRow(sc, "colourstore", X(xx[0]), Y(4.2), X(xx[1]), Y(18.8), z, 4.4, 4, 0, pal, "iso-rackf"); });
sc.tag(X(35), Y(11), z + 5.4, "Colour store", 6, "colourstore");
// plant room: pump sets, accumulators, compressor, oil tank
var nps = Math.max(1, Math.min(4, al.Dh + (al.Ch > 0 ? 1 : 0)));
sc.sprite("plantroom").seq([0, 1, 2, 3].slice(0, nps).map(function (k) {
return [X(41.3 + k * 1.6), Y(13.4), function (q) { q.box(X(40.7 + k * 1.6), Y(13.0), X(41.9 + k * 1.6), Y(13.8), z, z + 1.0, "iso-pump"); q.cyl(X(41.3 + k * 1.6), Y(13.4), 0.22, z + 1.0, z + 1.35, "iso-motor"); }];
}));
sc.sprite("plantroom").seq([0, 1, 2].map(function (k) { return [X(41 + k * 0.75), Y(19.6), function (q) { q.cyl(X(41 + k * 0.75), Y(19.6), 0.26, z, z + 2.2, "iso-accu"); }]; }));
sc.sprite("plantroom").box(X(45.4), Y(17.3), X(47.3), Y(19.0), z, z + 1.5, "iso-comp");
sc.sprite("plantroom").box(X(41), Y(16.0), X(43.6), Y(17.2), z, z + 1.2, "iso-oil");
sc.tag(X(44), Y(16.5), z + 3, "Plant room", 4, "plantroom");
// finishing and print block, ground floor (block floor plans): along the corridor cooling + optical inspection, QC lab,
// stair + goods lift; packaging behind with the cassette and CD-R benches; the upper floor is drawn at 1F
if (!up) finishingGF(sc, cfg, z);
// warehouse: three racking rows; dispatch: staged pallets and a forklift
[5, 11.5, 18].forEach(function (fy) { rackRow(sc, "warehouse", X(107.2), Y(fy), X(119.2), Y(fy + 1.2), z, 5.6, 4, 0, ["iso-pal-bx", "iso-pal-bx", "iso-pal-w", "", "iso-pal-bx"], "iso-rackf"); });
sc.tag(X(113), Y(12), z + 6.4, "Warehouse", 6, "warehouse");
[[122, 4.5], [124, 4.5], [126, 4.5], [122, 7], [126, 7]].forEach(function (p) { sc.sprite("dispatch").box(X(p[0] - 0.6), Y(p[1] - 0.5), X(p[0] + 0.6), Y(p[1] + 0.5), z, z + 1.3, "iso-carton"); });
var fk = sc.sprite("dispatch"); fk.box(X(123.4), Y(12), X(124.6), Y(14.2), z, z + 1.3, "iso-forklift"); fk.box(X(123.5), Y(11.6), X(124.5), Y(11.8), z, z + 2.6, "iso-steel");
sc.tag(X(124), Y(10), z + 2.6, "Dispatch", 3, "dispatch");
sc.tag(X(66), Y(16), z + 3.8, "Press hall", 7, "presshall");
sc.tag(X(19.5), Y(14), z + 3.4, cfg.galvanics ? "Galvanics" : "Galvanics (empty)", 6, "galvanics");
}
// toilets: pans, cubicle partitions, urinals and basins (schematic); fx/fy factory coordinates
function sanitary(sc, id, z, pans, parts, urinals, basins) {
var s = sc.sprite(id), q = [];
pans.forEach(function (p) { q.push([X(p[0]), Y(p[1]), function (r) { r.box(X(p[0] - p[2]), Y(p[1] - p[3]), X(p[0] + p[2]), Y(p[1] + p[3]), z, z + 0.42, "iso-sanit"); }]); });
parts.forEach(function (p) { q.push([X((p[0] + p[2]) / 2), Y((p[1] + p[3]) / 2), function (r) { r.box(X(p[0]), Y(p[1]), X(p[2]), Y(p[3]), z, z + 2.0, "iso-cubicle"); }]); });
urinals.forEach(function (p) { q.push([X((p[0] + p[2]) / 2), Y((p[1] + p[3]) / 2), function (r) { r.box(X(p[0]), Y(p[1]), X(p[2]), Y(p[3]), z + 0.45, z + 1.15, "iso-sanit"); }]); });
basins.forEach(function (p) { q.push([X((p[0] + p[2]) / 2), Y((p[1] + p[3]) / 2), function (r) { r.box(X(p[0]), Y(p[1]), X(p[2]), Y(p[3]), z + 0.75, z + 0.9, "iso-sanit"); }]); });
s.seq(q);
}
// finishing and print block, ground floor (fx 92..106)
function finishingGF(sc, cfg, z) {
// cooling racks and optical inspection (fx 92..96): spindle stacks from both presses cool first in, first out,
// then one station checks every record (inline-optical-qc.md, October 2026). No trimmer: the presses trim inline
// the station stands by the corridor glass (tour stop), the racks behind it
var cr = sc.sprite("cooling");
cr.box(X(92.4), Y(5.7), X(95.6), Y(6.4), z, z + 0.45, "iso-shelf");
cr.seq([0, 1, 2, 3, 4, 5].map(function (i) {
var fx = 92.68 + i * 0.53;
return [X(fx), Y(6.05), function (q) { q.cyl(X(fx), Y(6.05), 0.16, z + 0.45, z + 0.45 + (i % 3 === 1 ? 0.3 : 0.48), "iso-dome"); }];
}));
sc.tag(X(94), Y(6.05), z + 1.5, "Cooling racks", 3, "cooling");
qcStation(sc, "trim", X(93.6), Y(3.85), z, "");
sc.tag(X(94.5), Y(4.2), z + 2.4, "Optical QC", 4, "trim");
// QC lab (fx 96..100): bench on the packaging side
var qb = sc.sprite("qc"); qb.box(X(96.3), Y(6.7), X(99.7), Y(7.5), z, z + 0.85, "iso-bench");
qb.seq([[X(97), Y(7.1), function (q) { q.box(X(96.5), Y(6.8), X(97.5), Y(7.4), z + 0.85, z + 1.0, "iso-instr"); q.disc(X(97), Y(7.1), z + 1.02, 0.24, "iso-vinylc"); }],
[X(98.3), Y(7.1), function (q) { q.box(X(97.95), Y(6.85), X(98.65), Y(7.35), z + 0.85, z + 1.45, "iso-instr2"); }],
[X(99.3), Y(7.1), function (q) { q.box(X(99.0), Y(6.9), X(99.55), Y(7.3), z + 0.85, z + 1.6, "iso-speaker"); }]]);
sc.tag(X(98), Y(5), z + 2.4, "QC + listening", 4, "qc");
// stair and goods lift (fx 100..106), the same footprint on both levels
blockCore(sc, z, false);
sc.tag(X(103), Y(4.5), z + 3.0, "Stair + goods lift", 2, "block-stair");
// toilets at the end of the back aisle (fx 100..106, fy 20.5..24.7): women (2 WC), men (WC + urinal); doors to the aisle
sanitary(sc, "wc-e", z, [[100.7, 24.15, 0.2, 0.28], [101.9, 24.15, 0.2, 0.28], [105.2, 24.15, 0.2, 0.28]],
[[101.27, 23.0, 101.33, 24.7], [104.57, 23.0, 104.63, 24.7]], [[103.6, 24.48, 103.96, 24.66]],
[[102.5, 21.1, 102.95, 21.6], [103.1, 21.1, 103.5, 21.6]]);
sc.tag(X(103), Y(22.6), z + 2.6, "WC", 2, "wc-e");
// packaging (~170 m2): packing tables, shrink wrap, pallet wrapper, cartons
[[93, 13.9, 96, 15.1], [93, 10.9, 96, 12.1], [97, 10.9, 100, 12.1]].forEach(function (t) { sc.sprite("packaging").box(X(t[0]), Y(t[1]), X(t[2]), Y(t[3]), z, z + 0.9, "iso-bench"); });
var sh = sc.sprite("packaging"); sh.box(X(97), Y(14.8), X(99.8), Y(15.9), z, z + 1.3, "iso-shrink"); sh.open([X(97), Y(14.8)], [X(99.8), Y(14.8)], [[0.6, 2.2, z + 0.3, z + 1.1]], "iso-open");
var pw = sc.sprite("packaging"); pw.cyl(X(102.75), Y(15.25), 1.0, z, z + 0.15, "iso-steel"); pw.box(X(102.55), Y(16.45), X(102.95), Y(16.75), z, z + 2.4, "iso-steel");
pw.box(X(102.2), Y(14.7), X(103.3), Y(15.8), z + 0.15, z + 1.4, "iso-carton");
[[104.9, 12.4], [104.9, 13.8], [103.5, 12.4]].forEach(function (p) { sc.sprite("packaging").box(X(p[0] - 0.6), Y(p[1] - 0.5), X(p[0] + 0.6), Y(p[1] + 0.5), z, z + 1.2, "iso-carton"); });
sc.tag(X(98.5), Y(13), z + 2.6, "Packaging", 5, "packaging");
sc.tag(X(99), Y(9.5), z + 3.6, "Finishing", 5, "packaging");
if (cfg.cassette) {
var cb = sc.sprite("cassette"); cb.box(X(93), Y(18.7), X(96.6), Y(19.7), z, z + 0.85, "iso-bench");
cb.seq([0, 1, 2].map(function (k) { return [X(93.6 + k * 1.1), Y(19.2), function (q) { q.box(X(93.3 + k * 1.1), Y(18.9), X(94.0 + k * 1.1), Y(19.5), z + 0.85, z + 1.15, "iso-cass"); }]; }));
sc.tag(X(94.8), Y(19.2), z + 2.2, "Cassette", 3, "cassette");
}
if (cfg.cdr) {
var cd = sc.sprite("cdr"); cd.box(X(98), Y(18.7), X(100.6), Y(19.7), z, z + 0.85, "iso-bench"); cd.box(X(98.4), Y(18.85), X(99.2), Y(19.55), z + 0.85, z + 1.6, "iso-cdtower"); cd.led(X(98.8), Y(19.2), z + 1.62, "iso-ledg");
sc.tag(X(99.3), Y(19.2), z + 2.2, "CD-R", 3, "cdr");
}
}
// stair and goods lift of the block (fx 100..106, fy 2.6..7.9): a stair block on the ground floor, the stair well upstairs
function blockCore(sc, z, upper) {
var s = sc.sprite("block-stair"), st = [], k, a = X(100.4), b = X(101.5), c = Y(2.95), d = Y(7.5);
for (k = 1; k < 8; k++) st.push([a, c + k * 0.57, b, c + k * 0.57]);
if (!upper) { s.box(a, c, b, d, z, z + 1.6, "iso-stair"); s.lines(st, z + 1.61, "iso-steps"); }
else { s.flat(rect(a, c, b, d), z + 0.01, "iso-void", "block-stair"); s.lines(st, z + 0.02, "iso-steps"); s.box(b, c, b + 0.08, d, z, z + 1.0, "iso-rail2"); }
var lf = sc.sprite("block-stair"), L = sides(X(103.4), Y(2.95), X(105.6), Y(5.4));
lf.box(X(103.4), Y(2.95), X(105.6), Y(5.4), z, z + 2.4, "iso-lift", { top: "iso-cut" });
lf.open(L.N[0], L.N[1], [[0.5, 1.7, z, z + 2.1]], "iso-door").open(L.W[0], L.W[1], [[0.4, 1.6, z, z + 2.1]], "iso-door");
}
// 1F: the finishing and print block's upper floor at +3.8 m on a closed lower storey
function blockUpper(sc, cfg) {
var P = sc.P, z = FFL + BLK.up, a = X(BLK.fx0), b = X(105.85), c = Y(0.3), d = Y(FW - 0.3), t = 0.25;
var s = sc.sprite("printblock");
s.box(a, c, b, d, FFL, z, "iso-wall", { top: "iso-cut" });
var rooms = [[92, 0.3, 100, 11.95, "iso-z-print", "printroom"], [92, 11.95, 100, 24.7, "iso-z-stock", "printstock"],
[100, 7.9, 105.85, 24.7, "iso-z-packmat", "packmat"], [100, 0.3, 105.85, 7.9, "iso-z-core", "block-stair"]], segs = [];
rooms.forEach(function (r) {
var p = X(r[0]), q = Y(r[1]), u = X(r[2]), v = Y(r[3]);
s.flat(rect(p, q, u, v), z + 0.005, r[4] + " iso-t", r[5]);
segs.push([p, q, u, q], [u, q, u, v], [u, v, p, v], [p, v, p, q]);
});
s.lines(segs, z + 0.01, "iso-part");
// walls cut above the slab; the east side is the fire wall
[[a, c, b, c + t, 0, -1, "iso-wall"], [a, d - t, b, d, 0, 1, "iso-wall"], [a, c + t, a + t, d - t, -1, 0, "iso-wall"], [b - t, c + t, b, d - t, 1, 0, "iso-firewall"]].forEach(function (w) {
sc.sprite("printblock").box(w[0], w[1], w[2], w[3], z, z + (P.vis(w[4], w[5]) ? 1.0 : 2.4), w[6], { top: "iso-cut" });
});
// print stock: paper and board racking; packaging materials: sleeves, inner bags, mailers, cartons
[14.0, 18.0, 22.0].forEach(function (fy) { rackRow(sc, "printstock", X(92.6), Y(fy), X(99.4), Y(fy + 1.0), z, 2.2, 3, 0, ["iso-pal-w", "iso-pal-bx", "iso-pal-w", ""], "iso-shelf"); });
[10.0, 14.0, 18.0, 22.0].forEach(function (fy) { rackRow(sc, "packmat", X(100.6), Y(fy), X(105.2), Y(fy + 1.0), z, 2.2, 3, 0, ["iso-pal-bx", "iso-pal-w", "iso-pal-bx", ""], "iso-shelf"); });
// print room in reserve: a free position, nothing installed
sc.sprite("printroom").flat(rect(X(93), Y(3.9), X(98), Y(6.9)), z + 0.02, "iso-slot iso-empty", "printroom");
blockCore(sc, z, true);
sc.tag(X(95.5), Y(5.4), z + 1.6, "Print room (reserve)", 7, "printroom");
sc.tag(X(96), Y(18), z + 3.0, "Print stock", 6, "printstock");
sc.tag(X(103), Y(16), z + 3.0, "Packaging materials", 6, "packmat");
sc.tag(X(103), Y(4.5), z + 3.0, "Stair + goods lift", 3, "block-stair");
sc.tag(X(99), Y(12.5), z + 5.2, "Upper floor +3.8 m", 9, "printblock");
}
// Pipe bridge over the 6 m lane: the deck runs between the factory's north wall (y 94) and the
// yard building (y 101); at both ends the pipes drop down the wall face to low-level wall sleeves,
// so the run never ends in the air (the pump/HX room is lower than the deck, and the GF cutaway
// trims the walls below it). User feedback, October 2026.
function pipeBridge(sc, x) {
var s = sc.sprite("pipebridge"), Y0 = 94.3, Y1 = 100.7, parts = [];
s.box(x - 0.15, 96.85, x + 0.15, 97.15, 0, 5.0, "iso-steel", { noTop: true });
s.box(x - 0.15, 99.35, x + 0.15, 99.65, 0, 5.0, "iso-steel", { noTop: true });
s.box(x - 0.9, Y0, x + 0.9, Y1, 5.0, 5.3, "iso-steel");
// wall sleeves where the pipes enter
s.box(x - 0.85, 93.9, x + 0.85, 94.1, 0.3, 0.9, "iso-steel");
s.box(x - 0.85, 100.9, x + 0.85, 101.1, 0.3, 0.9, "iso-steel");
[[-0.7, -0.4, "iso-pipes"], [-0.2, 0.1, "iso-pipec"], [0.3, 0.6, "iso-pipec2"]].forEach(function (pp) {
var a = x + pp[0], b = x + pp[1], c = pp[2];
parts.push([(a + b) / 2, 97.5, function (q) { q.box(a, Y0, b, Y1, 5.3, 5.62, c); }]);
parts.push([(a + b) / 2, 94.2, function (q) { q.box(a, 94.0, b, Y0, 0.5, 5.62, c); }]);
parts.push([(a + b) / 2, 100.8, function (q) { q.box(a, Y1, b, 101.0, 0.5, 5.62, c); }]);
});
s.seq(parts);
}
// ------------------------------------------------------------ utility yard
function yard(sc, cfg, inside, pv) {
var P = sc.P, nb = Math.min(4, cfg.boilerModules), nc = Math.min(4, cfg.chillerModules), bio = cfg.fuel === "biomass", i;
var bx0 = 56, by0 = 101, bx1 = 74, by1 = 110, H = 6.0;
var bname = function (k) { return "boiler-B" + (k + 1); };
for (i = 0; i < 4; i++) {
if (i < nb) sc.inf(bname(i), "Boiler module B-" + (i + 1) + ", 500 kg/h, " + cfg.fuel,
(bio ? "Fired module; largest piece ~3.3 x 2.0 x 2.7 m (AIX DZL0.5 listing)" : "Electric module, ~3.0 x 1.5 m per 500 kg/h (vendor listing)") +
"; steam at 10 bar / 180 C to the press ring main" + (i === 2 && nb >= 3 ? " (B-3 is the N+1 position, scale.md)" : ""));
else sc.inf(bname(i), "Boiler bay B-" + (i + 1) + ": empty position", "The hall is built once for four 500 kg/h modules; modules arrive with the presses (scale.md utility phasing)");
}
sc.inf("boilerhall", "Boiler hall, " + nb + " of 4 modules (" + fmt(nb * 500) + " kg/h)",
"~18 x 9 m, 6.0 m high (estimate): four 3.5 m bays plus a 4 m bay for feed tank, softener, feed pumps and steam header" +
(cfg.boilerModules > 4 ? ". The fleet needs " + cfg.boilerModules + " modules: beyond the four-module hall, a utility phase of its own" : ""));
if (!inside) {
var s = sc.sprite("boilerhall"), S = sides(bx0, by0, bx1, by1);
s.box(bx0, by0, bx1, by1, 0, H, "iso-yardb", { top: "iso-roof2" });
for (i = 0; i < 4; i++) {
s.open(S.S[0], S.S[1], [[3.5 * i + 0.7, 3.5 * i + 2.8, 0, 3.4]], i < nb ? "iso-door iso-on" : "iso-door", bname(i));
s.open(S.S[0], S.S[1], [[3.5 * i + 0.9, 3.5 * i + 2.6, 4.0, 4.9]], i < nb ? "iso-win iso-lit" : "iso-win", bname(i));
}
s.open(S.S[0], S.S[1], [[15, 17, 0, 2.6]], "iso-door").open(S.E[0], S.E[1], [[2, 7, 4, 4.9]], "iso-louvre").open(S.W[0], S.W[1], [[2, 7, 4, 4.9]], "iso-louvre");
s.seq([0, 1, 2, 3].map(function (k) {
var cx = bx0 + 3.5 * k + 1.75;
return [cx, by0 + 6, function (q) {
q.box(cx - 0.9, by0 + 5.2, cx + 0.9, by0 + 6.8, H, H + 0.25, k < nb ? "iso-roofcap" : "iso-roofcap2", { id: bname(k) });
if (k < nb && !bio) { q.cyl(cx, by0 + 6, 0.2, H + 0.25, H + 1.4, "iso-steel"); if (cfg.running) q.puffs(cx, by0 + 6, H + 1.5, "iso-puff2", 3, 3.8); }
}];
}));
} else {
var f = sc.sprite("boilerhall");
f.box(bx0, by0, bx1, by1, 0, 0.15, "iso-plinth");
f.flat(rect(bx0, by0, bx1, by1), 0.16, "iso-z-yard iso-t", "boilerhall");
var bl = [];
for (i = 0; i <= 4; i++) bl.push([bx0 + 3.5 * i, by0 + 0.3, bx0 + 3.5 * i, by1 - 0.3]);
bl.push([bx0, by0 + 2.5, bx0 + 14, by0 + 2.5]);
f.lines(bl, 0.17, "iso-mark");
wallsCut(sc, "boilerhall", bx0, by0, bx1, by1, 0.15, 1.0, 3.0, 0.25, "iso-yardb");
for (i = 0; i < 4; i++) {
var cx = bx0 + 3.5 * i + 1.75, m = sc.sprite(bname(i));
if (i < nb) {
if (bio) {
m.box(cx - 1.0, by0 + 4.0, cx + 1.0, by0 + 7.3, 0.15, 2.85, "iso-boilerb");
m.box(cx - 0.4, by0 + 2.6, cx + 0.4, by0 + 4.0, 0.6, 1.3, "iso-steel");
m.open([cx - 1.0, by0 + 4.0], [cx + 1.0, by0 + 4.0], [[0.6, 1.4, 0.8, 1.6]], "iso-fire");
m.cyl(cx, by0 + 6.3, 0.22, 2.85, 4.0, "iso-steel");
} else {
m.box(cx - 0.75, by0 + 3.6, cx + 0.75, by0 + 6.6, 0.15, 2.05, "iso-boilere");
m.box(cx - 0.55, by0 + 3.2, cx + 0.55, by0 + 3.6, 0.15, 1.7, "iso-panel");
m.led(cx, by0 + 3.4, 1.72, "iso-ledg");
m.cyl(cx, by0 + 5.6, 0.16, 2.05, 3.2, "iso-steel");
if (cfg.running) m.puffs(cx, by0 + 5.6, 3.3, "iso-puff", 2, 3.4);
}
} else {
m.box(cx - 1.1, by0 + 3.4, cx + 1.1, by0 + 7.4, 0.15, 0.3, "iso-padempty");
sc.tag(cx, by0 + 5.4, 1.2, "B-" + (i + 1) + " free", 3, bname(i));
}
if (i < nb) sc.tag(cx, by0 + 5.0, 3.6, "B-" + (i + 1), 4, bname(i));
}
var hb = sc.sprite("boilerhall");
hb.seq([[bx0 + 15.2, by0 + 6.5, function (q) { q.cyl(bx0 + 15.2, by0 + 6.5, 0.9, 0.15, 2.6, "iso-tank"); }],
[bx0 + 16.9, by0 + 4.5, function (q) { q.cyl(bx0 + 16.9, by0 + 4.5, 0.35, 0.15, 1.8, "iso-softener"); q.cyl(bx0 + 16.9, by0 + 5.4, 0.35, 0.15, 1.8, "iso-softener"); }],
[bx0 + 16.6, by0 + 7.8, function (q) { q.box(bx0 + 15.6, by0 + 7.6, bx0 + 17.6, by0 + 8.3, 0.15, 0.9, "iso-pump"); }]]);
}
sc.tag((bx0 + bx1) / 2, (by0 + by1) / 2, inside ? 4.2 : H + 1.2, "Boiler hall " + nb + "/4", 9, "boilerhall");
if (cfg.boilerModules > 4) sc.tag(bx0 + 2, by0, inside ? 3.4 : H + 0.6, "+" + (cfg.boilerModules - 4) + " boilers beyond the hall", 99, "boilerhall", "iso-warntag");
// flue stack, biomass extras
var st = sc.sprite("stack");
st.cyl(65, 112.4, 0.5, 0, 14, "iso-stack");
st.cyl(65, 112.4, 0.58, 13.2, 14, "iso-stackband");
if (bio && cfg.running && nb > 0) st.puffs(65, 112.4, 14.2, "iso-smoke", 4, 5.5);
sc.inf("stack", "Flue stack, 14 m", bio ? "Biomass boiler flue; dust collectors and fans along the hall's back wall (scale.md)" : "Built with the yard for the fired case; idle while steam is electric");
sc.tag(65, 112.4, 14.6, "Stack", 2, "stack");
if (bio) {
var dc = sc.sprite("stack"); dc.cyl(60.5, 111.8, 0.65, 1.0, 4.6, "iso-steel"); dc.frust(60.5, 111.8, 0.18, 0.65, 0.2, 1.0, "iso-steel", 8);
sc.sprite("stack").box(61.9, 110.6, 63.5, 112.3, 0, 1.4, "iso-fanbox");
var fs = sc.sprite("fuelstore"), F = sides(74.5, 104, 81.5, 110);
fs.box(74.5, 104, 81.5, 110, 0, 5.0, "iso-yardb", { top: "iso-roof2" });
fs.open(F.S[0], F.S[1], [[0.5, 6.5, 0, 4.3]], "iso-open").open(F.S[0], F.S[1], [[0.6, 6.4, 0, 1.4]], "iso-chipsin");
sc.sprite("chips").frust(78, 102.1, 1.9, 0.5, 0, 1.6, "iso-chips", 10);
sc.inf("fuelstore", "Biomass fuel store, 42 m2", "5.0 m high; air-dry wood, ~350 to 400 t a year at ~1.0 million records (scale.md)");
sc.inf("chips", "Wood chip pile", "Fuel follows records pressed, not presses installed (scale.md)");
sc.tag(78, 107, 5.6, "Fuel store", 6, "fuelstore");
}
// pump and HX room
var px0 = 82.5, py0 = 101, px1 = 90.5, py1 = 106;
sc.inf("pumproom", "Cooling pump and heat-exchanger room", "~40 m2 (8 x 5 m), 4.0 m high: plate heat exchanger, cold tank and pumps behind the chiller bank (scale.md)");
if (!inside) {
var pr = sc.sprite("pumproom"), Q = sides(px0, py0, px1, py1);
pr.box(px0, py0, px1, py1, 0, 4.0, "iso-yardb", { top: "iso-roof2" });
pr.open(Q.S[0], Q.S[1], [[1, 2.6, 0, 2.5]], "iso-door").open(Q.S[0], Q.S[1], [[4, 7, 2.6, 3.4]], "iso-louvre").open(Q.E[0], Q.E[1], [[1, 4, 2.4, 3.3]], "iso-louvre");
} else {
var pf = sc.sprite("pumproom"); pf.box(px0, py0, px1, py1, 0, 0.15, "iso-plinth"); pf.flat(rect(px0, py0, px1, py1), 0.16, "iso-z-yard iso-t", "pumproom");
wallsCut(sc, "pumproom", px0, py0, px1, py1, 0.15, 1.0, 2.6, 0.25, "iso-yardb");
sc.sprite("pumproom").cyl(85, 104.2, 0.95, 0.15, 2.8, "iso-tank");
var hx = sc.sprite("pumproom"); hx.box(86.8, 103.6, 88.6, 104.2, 0.15, 1.9, "iso-hx");
sc.sprite("pumproom").seq([0, 1, 2].map(function (k) { return [87 + k * 1.0, 102.0, function (q) { q.box(86.7 + k * 1.0, 101.7, 87.3 + k * 1.0, 102.3, 0.15, 0.6, "iso-pump"); q.cyl(87 + k * 1.0, 102, 0.17, 0.6, 0.95, "iso-motor"); }]; }));
}
sc.tag((px0 + px1) / 2, (py0 + py1) / 2, inside ? 3 : 4.6, "Pumps + HX", 3, "pumproom");
// chiller compound: screened, open to the sky, four pads in two rows of two
var cx0 = 92, cy0 = 101, cx1 = 104.5, cy1 = 111.8;
var pads = [[93.8, 102.8], [98.7, 102.8], [93.8, 107.4], [98.7, 107.4]];
sc.inf("chillers", "Chiller compound, " + nc + " of 4 chillers (" + fmt(nc * 250) + " kW)",
"~12.5 x 10.8 m, open to the sky, screened (~2.5 m); two rows of two 4.0 x 2.6 m pads; no cooling tower (scale.md)" +
(cfg.chillerModules > 4 ? ". The fleet needs " + cfg.chillerModules + " modules: beyond the four-pad compound" : ""));
[["S", cx0, cy0, cx1, cy0 + 0.15], ["N", cx0, cy1 - 0.15, cx1, cy1], ["W", cx0, cy0 + 0.15, cx0 + 0.15, cy1 - 0.15], ["E", cx1 - 0.15, cy0 + 0.15, cx1, cy1 - 0.15]].forEach(function (w) {
var s = sc.sprite("chillers"), SS = sides(w[1], w[2], w[3], w[4]), L = [], k, len = (w[0] === "S" || w[0] === "N") ? w[3] - w[1] : w[4] - w[2];
s.box(w[1], w[2], w[3], w[4], 0, 2.5, "iso-screen");
for (k = 0; k < 6; k++) L.push([0.1, len - 0.1, 0.3 + k * 0.36, 0.42 + k * 0.36]);
var face = w[0] === "S" ? SS.S : w[0] === "N" ? SS.N : w[0] === "W" ? SS.W : SS.E;
s.open(face[0], face[1], L, "iso-slats");
var inner = w[0] === "S" ? SS.N : w[0] === "N" ? SS.S : w[0] === "W" ? SS.E : SS.W;
s.open(inner[0], inner[1], L, "iso-slats");
});
for (i = 0; i < 4; i++) {
var id = "chiller-CH" + (i + 1), p = pads[i], s2 = sc.sprite(id);
s2.box(p[0], p[1], p[0] + 4.0, p[1] + 2.6, 0, 0.2, i < nc ? "iso-pad" : "iso-padempty");
if (i >= nc) s2.flat(rect(p[0] + 0.25, p[1] + 0.25, p[0] + 3.75, p[1] + 2.35), 0.21, "iso-slot iso-empty", id);
if (i < nc) {
var ux0 = p[0] + 0.2, uy0 = p[1] + 0.18, ux1 = p[0] + 3.8, uy1 = p[1] + 2.42, U = sides(ux0, uy0, ux1, uy1), gl = [];
s2.box(ux0, uy0, ux1, uy1, 0.2, 2.48, "iso-chiller");
for (var k2 = 0; k2 < 6; k2++) gl.push([0.2 + k2 * 0.56, 0.62 + k2 * 0.56, 0.5, 2.1]);
s2.open(U.S[0], U.S[1], gl, "iso-grille").open(U.N[0], U.N[1], gl, "iso-grille");
s2.fan(p[0] + 1.1, p[1] + 1.3, 2.5, 0.7).fan(p[0] + 2.9, p[1] + 1.3, 2.5, 0.7);
s2.led(ux1 - 0.2, uy0, 1.6, "iso-ledg");
sc.inf(id, "Chiller CH-" + (i + 1) + ", 250 kW", "Air-cooled, rated at 40 C ambient (Carrier 30RB 080 to 090 class, 2.4 to 3.6 x 2.24 x 2.28 m); pad 4.0 x 2.6 m" + (i === 3 ? "; CH-4 is the N+1 reserve" : "") + " (scale.md)");
if (inside) sc.tag(p[0] + 2, p[1] + 1.3, 3.2, "CH-" + (i + 1), 4, id);
} else {
sc.inf(id, "Pad CH-" + (i + 1) + ": empty", "The compound is built once for four 250 kW chillers; this pad waits for its module (scale.md)");
sc.tag(p[0] + 2, p[1] + 1.3, 1.0, "CH-" + (i + 1) + " free", 3, id);
}
}
sc.tag((cx0 + cx1) / 2, (cy0 + cy1) / 2, 4.6, "Chillers " + nc + "/4", 9, "chillers");
if (cfg.chillerModules > 4) sc.tag(cx0 + 3, cy0, 3.4, "+" + (cfg.chillerModules - 4) + " chillers beyond the compound", 99, "chillers", "iso-warntag");
// raw water tank, transformer
var rw = sc.sprite("rawwater"); rw.cyl(108.5, 104.4, 2.5, 0, 3.0, "iso-water"); rw.disc(108.5, 104.4, 3.02, 2.0, "iso-tankring");
sc.inf("rawwater", "Raw water tank, 5 m x 3 m", "Phase 1 construction RFQ");
var tf = sc.sprite("transformer"), TS = sides(113, 102.2, 116, 105.2);
tf.box(113, 102.2, 116, 105.2, 0, 2.3, "iso-trafo");
var fins = []; for (var fk = 0; fk < 7; fk++) fins.push([0.25 + fk * 0.4, 0.45 + fk * 0.4, 0.3, 2.0]);
tf.open(TS.S[0], TS.S[1], fins, "iso-fins").open(TS.E[0], TS.E[1], fins, "iso-fins").open(TS.W[0], TS.W[1], fins, "iso-fins").open(TS.N[0], TS.N[1], fins, "iso-fins");
tf.seq([0, 1, 2].map(function (k) { return [113.8 + k * 0.7, 103.7, function (q) { q.cyl(113.8 + k * 0.7, 103.7, 0.12, 2.3, 2.9, "iso-bushing"); }]; }));
[[112.2, 101.4, 116.8, 101.5], [112.2, 106.0, 116.8, 106.1], [112.2, 101.5, 112.3, 106.0], [116.7, 101.5, 116.8, 106.0]].forEach(function (w) { sc.sprite("transformer").box(w[0], w[1], w[2], w[3], 0, 2.0, "iso-fence"); });
sc.inf("transformer", "PEA transformer",
(bio ? "800 to 1,000 kVA class without electric steam" : "1,250 to 1,600 kVA class with electric steam") + " (five-press sizing, scale.md); medium-voltage connection at 22 kV, PEA TOU 3.2.2");
sc.tag(114.5, 103.7, 3.6, "Transformer", 3, "transformer");
// battery (part of the solar package)
if (cfg.solar !== "none" && cfg.batteryKwh > 0) {
var packs = Math.max(1, Math.round(cfg.batteryKwh / 18)), Lb = Math.max(2.4, Math.min(13, packs * 0.4)), b0 = 119.5;
var bt = sc.sprite("battery"), BS = sides(b0, 101.8, b0 + Lb, 104.24), dl = [];
bt.box(b0, 101.8, b0 + Lb, 104.24, 0, 2.6, "iso-bess");
for (var dk = 0; dk < Math.floor(Lb / 2); dk++) dl.push([0.3 + dk * 2, 1.7 + dk * 2, 0.2, 2.3]);
bt.open(BS.S[0], BS.S[1], dl, "iso-bessdoor").open(BS.N[0], BS.N[1], dl, "iso-bessdoor");
bt.led(b0 + 0.3, 101.8, 2.2, "iso-ledg");
var iv = sc.sprite("battery");
iv.seq([0, 1].map(function (k) { return [b0 + Lb + 0.9 + k * 1.1, 102.4, function (q) { q.box(b0 + Lb + 0.5 + k * 1.1, 102.1, b0 + Lb + 1.3 + k * 1.1, 102.6, 0, 1.6, "iso-inv"); }]; }));
var ppa = cfg.solar === "ppa" ? " (PPA)" : "";
sc.inf("battery", "Battery " + fmt(cfg.batteryKwh) + " kWh" + ppa,
"Gobel LiFePO4 stack, " + packs + " x 18 kWh packs, Deye hybrid inverters; ride-through, not peak shaving (scale.md, sustainability.md)" + (ppa ? "; under the PPA the EPC owns the system" : ""));
sc.tag(b0 + Lb / 2, 103, 3.4, "Battery " + fmt(cfg.batteryKwh) + " kWh", 8, "battery");
}
// water treatment (galvanics in-house)
if (cfg.galvanics) {
var wx0 = 21, wy0 = 101, wx1 = 33, wy1 = 111, WH = 6.5;
sc.inf("wt", "Water treatment building, 12 x 10 m", "Zero-discharge effluent treatment, DI water plant and chemical store, directly behind galvanics; buried gravity duct and a covered crossing 6 m clear (site-candidates.md)");
if (!inside) {
var w = sc.sprite("wt"), WS = sides(wx0, wy0, wx1, wy1);
w.box(wx0, wy0, wx1, wy1, 0, WH, "iso-yardb", { top: "iso-roof2" });
w.open(WS.S[0], WS.S[1], [[1.5, 4, 0, 3]], "iso-door").open(WS.S[0], WS.S[1], [[7, 9, 3.6, 4.6], [9.6, 11.4, 3.6, 4.6]], "iso-win iso-lit")
.open(WS.E[0], WS.E[1], [[2, 4, 3.6, 4.6], [6, 8, 3.6, 4.6]], "iso-win").open(WS.W[0], WS.W[1], [[2, 8, 4.8, 5.6]], "iso-louvre");
w.box(wx0 + 7, wy0 + 5, wx0 + 8.5, wy0 + 6.5, WH, WH + 1.0, "iso-ac");
} else {
var wf = sc.sprite("wt"); wf.box(wx0, wy0, wx1, wy1, 0, 0.15, "iso-plinth"); wf.flat(rect(wx0, wy0, wx1, wy1), 0.16, "iso-z-galv iso-t", "wt");
wallsCut(sc, "wt", wx0, wy0, wx1, wy1, 0.15, 1.0, 3.0, 0.25, "iso-yardb");
sc.sprite("wt").cyl(24.5, 106.5, 1.3, 0.15, 4.6, "iso-evap");
sc.sprite("wt").seq([0, 1].map(function (k) { return [28 + k * 2.4, 103.6, function (q) { q.cyl(28 + k * 2.4, 103.6, 1.0, 0.15, 2.3, "iso-tank"); }]; }));
sc.sprite("wt").seq([0, 1].map(function (k) { return [29 + k * 0.8, 108.6, function (q) { q.cyl(29 + k * 0.8, 108.6, 0.32, 0.15, 1.7, "iso-softener"); }]; }));
sc.sprite("wt").box(31, 107.8, 32.4, 109.6, 0.15, 1.6, "iso-instr");
sc.sprite("wt").box(21.5, 102, 22.1, 105.5, 0.15, 1.8, "iso-shelf");
}
var cr = sc.sprite("wt");
cr.seq([[25.2, 94.4], [28.8, 94.4], [25.2, 100.6], [28.8, 100.6]].map(function (p) { return [p[0], p[1], function (q) { q.box(p[0] - 0.12, p[1] - 0.12, p[0] + 0.12, p[1] + 0.12, 0, 6.0, "iso-steel", { noTop: true }); }]; }));
cr.box(25, 94, 29, 101, 6.0, 6.3, "iso-roof2");
sc.tag(27, 106, inside ? 5.2 : WH + 1, "Water treatment", 7, "wt");
}
// PVC silos and big-bag station
// x ascending; from the default view (north-east) they read black, black, red, yellow (user, October 2026)
var cols = ["iso-siloy", "iso-silor", "iso-silok", "iso-silok"];
[37.5, 41.5, 45.5, 49.5].forEach(function (x, k) {
var s = sc.sprite("silos");
s.cyl(x, 103.6, 0.9, 0, 2.2, "iso-steel", { noTop: true });
s.frust(x, 103.6, 0.4, 1.7, 2.2, 3.6, cols[k], 10);
s.cyl(x, 103.6, 1.7, 3.6, 9.6, cols[k]);
s.frust(x, 103.6, 1.7, 0.35, 9.6, 10.3, cols[k], 10);
});
var bb = sc.sprite("silos"); bb.box(52.3, 101.6, 54.5, 104.2, 0, 3.4, "iso-frame"); bb.box(52.7, 102.2, 54.1, 103.6, 1.4, 2.6, "iso-bigbag");
sc.inf("silos", "PVC silos (proposed)", "Two for black, two for standard colours, ~50 m3 each (estimate); big-bag station; buried conveying lines to the colour store (site-candidates.md)");
sc.tag(43.5, 103.6, 11, "PVC silos", 6, "silos");
// pipe bridges across the lane
pipeBridge(sc, 72); pipeBridge(sc, 86.5);
return { nb: nb, nc: nc };
}
// ------------------------------------------------------------ office building
// Rooms after the floor plans (cnx_vinyl_parametric_payap-office.png): [u0, v0, u1, v1, zone, id] in metres
// from the west end (u) and from the south facade (v). Stair and lift sit in the same place on both storeys.
var OFC_GF = [
[0, 0, 6, 15, "iso-z-lobby", "office"], [6, 7.05, 32, 9.1, "iso-z-hall", "office"],
[6, 9.1, 10, 15, "iso-z-core", "office-stair"], [10, 9.1, 14.5, 15, "iso-z-staff", "changing"], [14.5, 9.1, 19, 15, "iso-z-staff2", "changing"],
[19, 9.1, 23, 15, "iso-z-arch", "archive"], [23, 9.1, 32, 15, "iso-z-cut", "cutting"],
[6, 0, 18, 7.05, "iso-z-canteen", "canteen"], [18, 0, 22, 7.05, "iso-z-kitchen", "canteen"], [22, 0, 26, 7.05, "iso-z-wc", "office-wc"],
[26, 0, 32, 7.05, "iso-z-listen", "listening"]
];
var OFC_1F = [
[0, 9.1, 6, 15, "iso-z-meet", "meetingroom"], [6, 9.1, 10, 15, "iso-z-core", "office-stair"], [10, 9.1, 13.5, 15, "iso-z-it", "itroom"],
[13.5, 9.1, 16.6, 15, "iso-z-wc", "wc-1f"], [16.6, 9.1, 19, 15, "iso-z-pantry", "pantry"], [19, 9.1, 32, 15, "iso-z-flex", "flexroom"],
[0, 7.05, 32, 9.1, "iso-z-hall", "office"], [0, 0, 22, 7.05, "iso-z-offices", "offices"], [22, 0, 32, 7.05, "iso-z-meet2", "training"]
];
var OFC_UP = 4.0; // storey height: first floor at z0 + 4.0 (8 m to the roof for two storeys)
function officeInfo(sc, cfg) {
sc.inf("office", "Office building, 32 x 15 m",
"PV-ready flat roof (structure for ~65 kWp of panels, conduit to the main board; panels later). Two storeys (~960 m2) with a stair and a lift. Ground floor: reception and lobby, canteen, changing rooms, toilets, mastering and cutting, listening room, archive. " +
"First floor: offices, meeting and training, a meeting room, IT and server room, toilets, pantry, a flexible room. Isolated from the factory floor, joined by a covered link (site-candidates.md).");
sc.inf("cutting", cfg.cutting ? "Mastering and cutting room" : "Cutting room: cut abroad",
cfg.cutting ? "Sillitoe SDMS lathe (USD 125k list) on the ground slab, away from press vibration and outside the crane zone; inner acoustic lining" :
"Lacquers are cut by an outside studio (SST Frankfurt cuts Poy Records' lacquers today); the room stays for mastering");
sc.inf("lathe", "Sillitoe SDMS cutting lathe", "Integrated Space / Time Control automation, USD 125k published list price (vendor-landscape.md)");
sc.inf("archive", "Archive: mobile shelving", "The father and the mother of every side; two fixed runs and four double-sided carriages, ~150 shelf metres, ~50,000 parts, on the ground slab (facility-security.md, estimates)");
sc.inf("listening", "Listening room", "Start of the tour with the cutting-room window");
sc.inf("canteen", "Canteen and kitchen", "For about 40");
sc.inf("changing", "Changing rooms", "Women and men, four showers each; staff change here before crossing to the factory");
sc.inf("link", "Covered link", "From the office lobby to the door at the start of the visitor corridor, ramping up to the factory floor at +1.2 m (site-candidates.md)");
sc.inf("office-stair", "Stair and lift", "Connect the two storeys of the office building (site-candidates.md)");
sc.inf("office-wc", "Toilets", "Ground floor of the office building");
sc.inf("offices", "Offices, ~150 m2", "First floor of the office building (site-candidates.md)");
sc.inf("training", "Meeting and training room", "For the Payap programmes: mastering and lathe cutting, IT, business administration (payap-partnership.md); first floor");
sc.inf("meetingroom", "Meeting room", "First floor of the office building");
sc.inf("itroom", "IT and server room", "First floor of the office building");
sc.inf("wc-1f", "Toilets", "First floor of the office building");
sc.inf("pantry", "Pantry", "First floor of the office building");
sc.inf("flexroom", "Flexible room", "A second classroom or more offices; first floor (site-candidates.md)");
}
// facade openings: ground floor band always, the upper band only on the closed building (roof level)
function officeWindows(s, S, o, both) {
var gl = [], ul = [], wl = [], u, i;
for (u = 8.5, i = 0; u < 31; u += 3, i++) (hash(i + 3) > 0.35 ? gl : wl).push([u, u + 1.8, o.z0 + 0.9, o.z0 + 2.7]);
if (both) for (u = 1, i = 0; u < 31; u += 3, i++) (hash(i + 9) > 0.4 ? ul : wl).push([u, u + 1.8, o.z0 + 4.8, o.z0 + 6.8]);
s.open(S.S[0], S.S[1], wl, "iso-win").open(S.S[0], S.S[1], gl.concat(ul), "iso-win iso-lit").open(S.S[0], S.S[1], [[0.6, 5.6, o.z0, o.z0 + 3.2]], "iso-glass", "office");
["E", "W", "N"].forEach(function (k, j) {
var L = k === "N" ? 32 : 15, a = [], b = [];
for (u = 1.2, i = 0; u < L - 1.5; u += 3, i++) {
if (!(k === "N" && u + 1.8 > o.x1 - LINK.x1 && u < o.x1 - LINK.x0)) (hash(i + j * 17) > 0.45 ? a : b).push([u, u + 1.8, o.z0 + 0.9, o.z0 + 2.7]);
if (both) (hash(i + j * 29 + 5) > 0.45 ? a : b).push([u, u + 1.8, o.z0 + 4.8, o.z0 + 6.8]);
}
s.open(S[k][0], S[k][1], b, "iso-win").open(S[k][0], S[k][1], a, "iso-win iso-lit");
});
s.open(S.N[0], S.N[1], [[o.x1 - LINK.x1 + 0.7, o.x1 - LINK.x0 - 0.7, o.z0, o.z0 + 2.5]], "iso-door", "link");
}
function office(sc, cfg, level, pv) {
var o = OFC, x0 = o.x0, y0 = o.y0, x1 = o.x1, y1 = o.y1, S = sides(x0, y0, x1, y1);
officeInfo(sc, cfg);
// covered link (all levels)
var lk = sc.sprite("link"), la = LINK.x0, lb = LINK.x1;
lk.prism(rect(la + 0.2, y1, lb - 0.2, FY), 0, [o.z0, o.z0, FFL, FFL], "iso-plinth", { top: "iso-g-path" });
lk.seq([[la + 0.2, 57.4], [lb - 0.2, 57.4], [la + 0.2, 68.6], [lb - 0.2, 68.6]].map(function (p) { return [p[0], p[1], function (q) { q.box(p[0] - 0.1, p[1] - 0.1, p[0] + 0.1, p[1] + 0.1, 0, 4.0, "iso-steel", { noTop: true }); }]; }));
lk.box(la, y1, lb, FY, 4.0, 4.25, "iso-roof3");
if (level === "roof") {
var s = sc.sprite("office");
s.box(x0, y0, x1, y1, 0, o.z0, "iso-plinth");
s.box(x0, y0, x1, y1, o.z0, o.z1, "iso-office", { top: "iso-roof3" });
officeWindows(s, S, o, true);
s.flat(rect(x0 + 0.4, y0 + 0.4, x1 - 0.4, y1 - 0.4), o.z1 + 0.01, "iso-roofin");
if (pv) ["offW", "offM", "offE"].forEach(function (k) { if (pv.by[k]) pvDraw(s, pv.by[k].F, pv.by[k].panels); });
s.seq([[x0 + 20.8, y0 + 5.6], [x0 + 23.4, y0 + 5.6], [x0 + 26, y0 + 9.4]].map(function (p) {
return [p[0], p[1], function (q) { q.box(p[0] - 0.8, p[1] - 0.6, p[0] + 0.8, p[1] + 0.6, o.z1, o.z1 + 1.0, "iso-ac"); q.disc(p[0], p[1], o.z1 + 1.01, 0.42, "iso-fanring"); }];
}));
sc.tag((x0 + x1) / 2, (y0 + y1) / 2, o.z1 + 1.5, "Office", 9, "office");
return;
}
var up = level === "1f", z = up ? o.z0 + OFC_UP : o.z0, f = sc.sprite("office"), segs = [];
f.box(x0, y0, x1, y1, 0, o.z0, "iso-plinth");
if (up) { f.box(x0, y0, x1, y1, o.z0, z, "iso-office", { top: "iso-cut" }); officeWindows(f, S, o, false); }
(up ? OFC_1F : OFC_GF).forEach(function (r) {
var cls = r[5] === "cutting" && !cfg.cutting ? "iso-z-floor" : r[4], a = x0 + r[0], b = y0 + r[1], c = x0 + r[2], d = y0 + r[3];
f.flat(rect(a, b, c, d), z + 0.005, cls + " iso-t", r[5]);
segs.push([a, b, c, b], [c, b, c, d], [c, d, a, d], [a, d, a, b]);
});
f.lines(segs, z + 0.01, "iso-part");
wallsCut(sc, "office", x0, y0, x1, y1, z, 1.0, 3.0, 0.25, "iso-office");
officeCore(sc, z, up);
if (up) officeUpper(sc, cfg, z); else officeGround(sc, cfg, z);
}
// stair (u 6.2..7.4) and lift (u 8.0..9.8) on the north side, off the corridor
function officeCore(sc, z, up) {
var x0 = OFC.x0, y0 = OFC.y0, s = sc.sprite("office-stair"), st = [], k, a = x0 + 6.2, b = x0 + 7.4, c = y0 + 9.3, d = y0 + 14.8;
for (k = 1; k < 9; k++) st.push([a, c + k * 0.61, b, c + k * 0.61]);
if (!up) { s.box(a, c, b, d, z, z + 1.6, "iso-stair"); s.lines(st, z + 1.61, "iso-steps"); }
else { s.flat(rect(a, c, b, d), z + 0.01, "iso-void", "office-stair"); s.lines(st, z + 0.02, "iso-steps"); s.box(b, c, b + 0.08, d, z, z + 1.0, "iso-rail2"); }
var lf = sc.sprite("office-stair"), L = sides(x0 + 8.0, y0 + 12.8, x0 + 9.8, y0 + 14.65);
lf.box(x0 + 8.0, y0 + 12.8, x0 + 9.8, y0 + 14.65, z, z + 2.4, "iso-lift", { top: "iso-cut" });
lf.open(L.S[0], L.S[1], [[0.4, 1.4, z, z + 2.1]], "iso-door");
}
function officeGround(sc, cfg, z) {
var x0 = OFC.x0, y0 = OFC.y0;
// lobby: reception desk near the entrance
sc.sprite("office").box(x0 + 1, y0 + 2.0, x0 + 3.2, y0 + 2.8, z, z + 1.1, "iso-desk");
sc.tag(x0 + 3, y0 + 7, z + 2.6, "Reception", 3, "office");
// mastering and cutting (north-east), inner acoustic lining
sc.sprite("cutting").flat(rect(x0 + 23.3, y0 + 9.4, x0 + 31.7, y0 + 14.7), z + 0.012, "iso-lining", "cutting");
if (cfg.cutting) {
var lt = sc.sprite("lathe");
lt.box(x0 + 24.2, y0 + 10.4, x0 + 26.4, y0 + 11.7, z, z + 1.0, "iso-lathe");
lt.seq([[x0 + 24.9, y0 + 11.05, function (q) { q.disc(x0 + 24.9, y0 + 11.05, z + 1.02, 0.42, "iso-platter"); q.disc(x0 + 24.9, y0 + 11.05, z + 1.04, 0.36, "iso-lacquer"); }],
[x0 + 25.9, y0 + 11.05, function (q) { q.box(x0 + 25.65, y0 + 10.7, x0 + 26.15, y0 + 11.4, z + 1.0, z + 1.5, "iso-steel"); q.box(x0 + 25.2, y0 + 10.95, x0 + 25.8, y0 + 11.15, z + 1.4, z + 1.55, "iso-steel"); }]]);
lt.led(x0 + 26.4, y0 + 10.7, z + 0.8, "iso-ledo");
var md = sc.sprite("cutting"); md.box(x0 + 27.5, y0 + 12.8, x0 + 29.7, y0 + 13.7, z, z + 0.75, "iso-desk");
md.seq([[x0 + 28.1, y0 + 13.5, function (q) { q.box(x0 + 27.8, y0 + 13.45, x0 + 28.4, y0 + 13.6, z + 0.75, z + 1.2, "iso-screenm"); }],
[x0 + 29.1, y0 + 13.5, function (q) { q.box(x0 + 28.8, y0 + 13.45, x0 + 29.4, y0 + 13.6, z + 0.75, z + 1.2, "iso-screenm"); }]]);
sc.sprite("cutting").box(x0 + 27.0, y0 + 14.0, x0 + 27.6, y0 + 14.5, z, z + 1.1, "iso-speaker");
sc.sprite("cutting").box(x0 + 29.6, y0 + 14.0, x0 + 30.2, y0 + 14.5, z, z + 1.1, "iso-speaker");
sc.tag(x0 + 25.3, y0 + 11, z + 2.6, "Lathe", 7, "lathe");
} else {
var sg = sc.sprite("cutting"); sg.box(x0 + 25.9, y0 + 11, x0 + 26.05, y0 + 12.6, z, z + 1.0, "iso-steel"); sg.box(x0 + 25.85, y0 + 10.9, x0 + 26.1, y0 + 12.7, z + 1.0, z + 2.0, "iso-sign");
sc.tag(x0 + 26, y0 + 11.8, z + 2.6, "Cut abroad", 100, "cutting", "iso-signtag");
}
sc.tag(x0 + 28, y0 + 12.5, z + 3.4, cfg.cutting ? "Cutting room" : "Cutting room (empty)", 6, "cutting");
// listening room (south-east): seats facing the speakers on the corridor wall
sc.sprite("listening").box(x0 + 28.0, y0 + 2.0, x0 + 30.5, y0 + 2.8, z, z + 0.6, "iso-sofa");
sc.sprite("listening").box(x0 + 26.6, y0 + 6.2, x0 + 27.2, y0 + 6.7, z, z + 1.2, "iso-speaker");
sc.sprite("listening").box(x0 + 30.8, y0 + 6.2, x0 + 31.4, y0 + 6.7, z, z + 1.2, "iso-speaker");
sc.tag(x0 + 29, y0 + 3.5, z + 2.6, "Listening", 3, "listening");
// archive: two fixed runs, four double-sided carriages and one aisle (rails along the long side)
var ar = [[9.55, 9.95], [10.0, 10.8], [10.85, 11.65], [11.7, 12.5], [13.4, 14.2], [14.25, 14.65]];
sc.sprite("archive").seq(ar.map(function (r) {
return [x0 + 21, y0 + (r[0] + r[1]) / 2, function (q) {
var S = sides(x0 + 19.5, y0 + r[0], x0 + 22.5, y0 + r[1]), L = [], lv;
q.box(x0 + 19.5, y0 + r[0], x0 + 22.5, y0 + r[1], z, z + 2.2, "iso-archive");
for (lv = 0; lv < 5; lv++) L.push([0.1, 2.9, z + 0.15 + lv * 0.42, z + 0.42 + lv * 0.42]);
q.open(S.S[0], S.S[1], L, "iso-boxes").open(S.N[0], S.N[1], L, "iso-boxes");
}];
}));
sc.tag(x0 + 21, y0 + 12, z + 3.0, "Archive", 6, "archive");
// canteen tables (3 x 2), kitchen servery and line
var tl = []; for (var tx = 0; tx < 3; tx++) for (var ty = 0; ty < 2; ty++) tl.push([x0 + 8.2 + tx * 3.5, y0 + 1.7 + ty * 3.2]);
sc.sprite("canteen").seq(tl.map(function (p) { return [p[0], p[1], function (q) { q.box(p[0] - 1.2, p[1] - 0.5, p[0] + 1.2, p[1] + 0.5, z, z + 0.75, "iso-table"); }]; }));
sc.sprite("canteen").box(x0 + 18.2, y0 + 3.0, x0 + 19.0, y0 + 4.4, z, z + 0.9, "iso-bench");
sc.sprite("canteen").box(x0 + 18.4, y0 + 0.5, x0 + 21.6, y0 + 1.3, z, z + 0.9, "iso-steel");
sc.tag(x0 + 12, y0 + 3.5, z + 2.2, "Canteen", 3, "canteen");
// changing rooms, women and men: showers on the north wall, lockers, a bench
[0, 4.5].forEach(function (dx) {
var cr = sc.sprite("changing");
cr.box(x0 + 10.4 + dx, y0 + 13.7, x0 + 14.1 + dx, y0 + 14.6, z, z + 2.0, "iso-shower");
cr.box(x0 + 13.5 + dx, y0 + 9.7, x0 + 14.1 + dx, y0 + 13.3, z, z + 1.9, "iso-locker");
cr.box(x0 + 10.6 + dx, y0 + 11.5, x0 + 12.8 + dx, y0 + 11.9, z, z + 0.45, "iso-bench");
});
sc.tag(x0 + 14.5, y0 + 12, z + 2.8, "Changing", 3, "changing");
// toilets
sc.sprite("office-wc").box(x0 + 22.4, y0 + 4.4, x0 + 25.6, y0 + 6.6, z, z + 2.0, "iso-wc");
}
function officeUpper(sc, cfg, z) {
var o = OFC, x0 = o.x0, y0 = o.y0, list = [], r, c;
// offices (~150 m2): sixteen desks in two rows
for (r = 0; r < 2; r++) for (c = 0; c < 8; c++) list.push([x0 + 1.0 + c * 2.6, y0 + (r ? 4.4 : 1.2)]);
sc.sprite("offices").seq(list.map(function (p) { return [p[0] + 0.8, p[1] + 0.4, function (q) { q.box(p[0], p[1], p[0] + 1.6, p[1] + 0.8, z, z + 0.75, "iso-desk"); }]; }));
// meeting and training room (Payap programmes)
sc.sprite("training").seq([2.4, 3.7].map(function (v) { return [x0 + 27, y0 + v + 0.35, function (q) { q.box(x0 + 23.2, y0 + v, x0 + 30.8, y0 + v + 0.7, z, z + 0.75, "iso-table"); }]; }));
// meeting room, IT racks, toilets, pantry counter
sc.sprite("meetingroom").box(x0 + 1.0, y0 + 11.5, x0 + 5.0, y0 + 12.9, z, z + 0.75, "iso-table");
var it = sc.sprite("itroom"), IS = sides(x0 + 10.6, y0 + 12.8, x0 + 12.4, y0 + 13.85);
it.box(x0 + 10.6, y0 + 12.8, x0 + 12.4, y0 + 13.85, z, z + 2.0, "iso-server");
it.open(IS.S[0], IS.S[1], [[0.1, 0.55, z + 0.2, z + 1.85], [0.65, 1.15, z + 0.2, z + 1.85], [1.25, 1.7, z + 0.2, z + 1.85]], "iso-grille");
it.led(x0 + 11.5, y0 + 12.8, z + 1.7, "iso-ledg");
sc.sprite("wc-1f").box(x0 + 13.8, y0 + 12.5, x0 + 16.3, y0 + 14.6, z, z + 2.0, "iso-wc");
sc.sprite("pantry").box(x0 + 16.8, y0 + 14.05, x0 + 18.7, y0 + 14.65, z, z + 0.9, "iso-bench");
sc.tag((o.x0 + o.x1) / 2, y0 + 8, z + 4.4, "Office, first floor", 9, "office");
sc.tag(x0 + 11, y0 + 4, z + 2.2, "Offices ~150 m2", 7, "offices");
sc.tag(x0 + 27, y0 + 4.5, z + 2.2, "Meeting + training", 6, "training");
sc.tag(x0 + 25.5, y0 + 12, z + 2.2, "Flexible room", 5, "flexroom");
sc.tag(x0 + 3, y0 + 12, z + 2.2, "Meeting", 4, "meetingroom");
sc.tag(x0 + 11.8, y0 + 12, z + 3.0, "IT + server", 4, "itroom");
sc.tag(x0 + 17.8, y0 + 12, z + 2.2, "Pantry", 2, "pantry");
sc.tag(x0 + 15, y0 + 11, z + 2.6, "WC", 1, "wc-1f");
sc.tag(x0 + 8, y0 + 12, z + 3.0, "Stair + lift", 2, "office-stair");
}
// ------------------------------------------------------------ phase 2
function phase2(sc, cfg, inside, al, pv) {
var b = P2, P = sc.P, zS = FFL + b.eave, zN = FFL + b.high, n = al.ovD + al.ovC;
sc.inf("phase2", "Phase 2 building" + (al.p2need ? ": " + n + " press" + (n === 1 ? "" : "es") : " (reserved)"),
(al.p2need ? n + " press" + (n === 1 ? "" : "es") + " beyond the ~870 m2 press hall. " : "") +
"A second building is expansion beyond the current program: more pressing, or finished goods moved out to free production floor (scale.md). Drawn schematically; size and position not planned yet.");
var lk = sc.sprite("phase2");
lk.seq([[72.2, 57.4], [75.8, 57.4], [72.2, 68.6], [75.8, 68.6]].map(function (p) { return [p[0], p[1], function (q) { q.box(p[0] - 0.1, p[1] - 0.1, p[0] + 0.1, p[1] + 0.1, 0, 3.6, "iso-steel", { noTop: true }); }]; }));
lk.box(72, 57, 76, 69, 3.6, 3.85, "iso-roof3");
if (!inside) {
var s = sc.sprite("phase2"), S = sides(b.x0, b.y0, b.x1, b.y1), w = [], l = [], u, i;
s.box(b.x0, b.y0, b.x1, b.y1, 0, FFL, "iso-plinth");
s.prism(rect(b.x0, b.y0, b.x1, b.y1), FFL, [zS, zS, zN, zN], "iso-wall2", { top: "iso-roof" });
for (u = 2, i = 0; u < 40; u += 4, i++) (al.p2need && hash(i + 70) > 0.3 ? l : w).push([u, u + 2.6, FFL + 4.2, FFL + 5.4]);
s.open(S.S[0], S.S[1], w, "iso-win").open(S.S[0], S.S[1], l, "iso-win iso-lit").open(S.S[0], S.S[1], [[4, 8, FFL, FFL + 4.5]], "iso-door2");
s.open(S.E[0], S.E[1], [[3, 6, FFL + 4.4, FFL + 5.4], [10, 13, FFL + 4.6, FFL + 5.8], [17, 20, FFL + 4.9, FFL + 6.1]], al.p2need ? "iso-win iso-lit" : "iso-win");
s.open(S.N[0], S.N[1], [[18, 22, FFL, FFL + 3.4]], "iso-door");
var pp = pv.by.p2; if (pp) pvDraw(s, pp.F, pp.panels);
sc.tag((b.x0 + b.x1) / 2, (b.y0 + b.y1) / 2, zN + 1, al.p2need ? "Phase 2 (" + n + " presses)" : "Phase 2 (reserved)", 9, "phase2");
return;
}
var f = sc.sprite("phase2"), z = FFL;
f.box(b.x0, b.y0, b.x1, b.y1, 0, FFL, "iso-plinth");
f.flat(rect(b.x0, b.y0, b.x1, b.y1), z + 0.005, "iso-z-press iso-t", "phase2");
f.flat(rect(b.x0 + 0.3, 51.5, b.x1 - 0.3, b.y1 - 0.3), z + 0.01, "iso-z-wh iso-t", "phase2");
f.lines([[b.x0 + 1, 42.5, b.x1 - 1, 42.5]], z + 0.01, "iso-trenchl");
var eC = 0;
al.p2.forEach(function (sl) {
var pts = rect(sl.x0 + 0.25, sl.y0 + 0.25, sl.x1 - 0.25, sl.y1 - 0.25);
if (sl.e) { var id = "pos2-c" + (++eC); f.flat(pts, z + 0.015, "iso-slot iso-empty", id); sc.inf(id, "Phase 2 position (free)", "Floor in the second building"); }
else f.flat(pts, z + 0.015, "iso-slot", sl.t === "D" ? "duplex-" + sl.n : "classic-" + sl.n);
});
wallsCut(sc, "phase2", b.x0, b.y0, b.x1, b.y1, z, 1.0, 3.0, 0.3, "iso-wall2");
al.p2.forEach(function (sl) {
if (sl.e) return;
var cx = (sl.x0 + sl.x1) / 2, cy = (sl.y0 + sl.y1) / 2;
if (sl.t === "D") { duplexCell(sc, sl.n, cx, cy, z, cfg.running); sc.tag(cx, cy, z + 3.2, "Duplex " + sl.n, 8, "duplex-" + sl.n); }
else { classicM(sc, sl.n, cx, cy, z, cfg.running); sc.tag(cx, cy, z + 3.2, "Classic " + sl.n, 6, "classic-" + sl.n); }
});
[52.4, 54.6].forEach(function (y) { rackRow(sc, "phase2", b.x0 + 3, y, b.x1 - 3, y + 1.1, z, 4.0, 3, 0, ["iso-pal-bx", "iso-pal-w", "", "iso-pal-bx"], "iso-rackf"); });
sc.tag((b.x0 + b.x1) / 2, b.y0 + 2, z + 3.5, al.p2need ? "Phase 2" : "Phase 2 (reserved)", 9, "phase2");
}
// ------------------------------------------------------------ vehicles
function truckDraw(s, cx, cy, dir, kind) {
var along = dir === "E" || dir === "W", sg = dir === "E" || dir === "N" ? 1 : -1, P = s.P;
function R(a0, a1, b0, b1) {
var A0 = sg > 0 ? a0 : -a1, A1 = sg > 0 ? a1 : -a0;
return along ? [cx + A0, cy + b0, cx + A1, cy + b1] : [cx + b0, cy + A0, cx + b1, cy + A1];
}
function B(r, z0, z1, mat, o) { s.box(r[0], r[1], r[2], r[3], z0, z1, mat, o); }
var ch = R(-5, 5, -1.1, 1.1); B(ch, 0.3, 1.0, "iso-chassis");
var cab = R(2.7, 5, -1.25, 1.25), body = R(-5, 2.45, -1.25, 1.25);
var ck = along ? [(cab[0] + cab[2]) / 2, cy] : [cx, (cab[1] + cab[3]) / 2], bk = along ? [(body[0] + body[2]) / 2, cy] : [cx, (body[1] + body[3]) / 2];
var parts = [[ck[0], ck[1], function () {
B(cab, 1.0, 3.1, "iso-cab");
var fr = along ? (sg > 0 ? [[cab[2], cab[1]], [cab[2], cab[3]]] : [[cab[0], cab[3]], [cab[0], cab[1]]]) : (sg > 0 ? [[cab[2], cab[3]], [cab[0], cab[3]]] : [[cab[0], cab[1]], [cab[2], cab[1]]]);
s.open(fr[0], fr[1], [[0.25, 2.25, 2.0, 2.9]], "iso-glassd");
}], [bk[0], bk[1], function () {
if (kind === "box") { B(body, 1.0, 3.7, "iso-truckbox"); }
else {
B(body, 1.0, 1.3, "iso-chassis");
var segs = along ? [[-4.7, -2.6], [-2.4, -0.3], [-0.1, 2.1]] : [[-4.7, -2.6], [-2.4, -0.3], [-0.1, 2.1]];
s.seq(segs.map(function (q) { var r = R(q[0], q[1], -1.1, 1.1); return [(r[0] + r[2]) / 2, (r[1] + r[3]) / 2, function () { B(r, 1.3, 2.35, "iso-sacks"); }]; }));
}
}]];
parts.sort(function (a, b) { return P.depth(a[0], a[1]) - P.depth(b[0], b[1]); }).forEach(function (p) { p[2](); });
}
function vehicles(sc, cfg) {
var P = sc.P;
sc.inf("truck-in", "Truck: PVC compound in", "Sacks of compound for the colour store and the presses");
sc.inf("truck-out", "Truck: finished records out", "Pallets from dispatch");
function mover(id, cls, x0, y0, dir, kind, dx, dy, bbx) {
var s = sc.sprite(id, "iso-mv"), v = P.dv(dx, dy, 0);
s.extBox(bbx[0], bbx[1], bbx[2], bbx[3], 0, 3.8);
s.ga("iso-a " + cls, "--dx:" + r1(v[0]) + "px;--dy:" + r1(v[1]) + "px");
truckDraw(s, x0, y0, dir, kind);
s.ge();
}
if (cfg.running) {
mover("truck-in", "iso-tin-r", -3, -8.5, "E", "sacks", 124, 0, [-8.5, -9.8, 126.2, -7.2]);
mover("truck-in", "iso-tin-g", 121, -1, "N", "sacks", 0, 50, [119.7, -6.2, 122.3, 54.2]);
mover("truck-in", "iso-tin-d", 121, 63.6, "S", "sacks", 0, 0, [119.7, 58.4, 122.3, 68.65]);
mover("truck-out", "iso-tout-g", 127, 63.6, "S", "box", 0, -64.6, [125.7, -6.2, 128.3, 68.65]);
mover("truck-out", "iso-tout-r", 127, -13.5, "W", "box", -130, 0, [-8.5, -14.8, 132.2, -12.2]);
}
var a = sc.sprite("truck-in", cfg.running ? "iso-park" : ""); truckDraw(a, 121, 63.6, "S", "sacks");
var b = sc.sprite("truck-out", cfg.running ? "iso-park" : ""); truckDraw(b, 127, 63.6, "S", "box");
}
// ------------------------------------------------------------ scene
// level: "roof" (closed buildings, PV), "gf" (ground-floor cutaway), "1f" (upper floors: the office first floor and
// the finishing and print block at +3.8 m; single-storey buildings stay in their ground-floor cutaway, ghosted)
function levelOf(m, d) {
m = String(m === undefined || m === null ? "" : m).toLowerCase();
return m === "roof" ? "roof" : (m === "gf" || m === "inside" || m === "ground") ? "gf" : (m === "1f" || m === "upper") ? "1f" : d;
}
function buildScene(cfg, level, rot, uid) {
level = levelOf(level, "roof");
var sc = new Scene(rot, uid), inside = level !== "roof", up = level === "1f", al = allocate(cfg), pv = pvPlace(cfg, al), P = sc.P;
var ctx = up ? Object.assign({}, cfg, { running: false }) : cfg; // context buildings stand still at 1F
siteGround(sc, cfg, al);
sitePieces(sc, cfg, al);
parking(sc, cfg, pv);
factoryInfo(sc, cfg, al);
if (inside) { sc.dim = up; factoryInside(sc, ctx, al, pv, up); sc.dim = false; if (up) blockUpper(sc, cfg); }
else factoryRoof(sc, cfg, pv);
office(sc, cfg, level, pv);
var y = yard(sc, cfg, inside, pv);
if (al.p2show) { sc.dim = up; phase2(sc, ctx, inside, al, pv); sc.dim = false; }
else {
sc.inf("phase2", "Phase 2 reserve", "Ground kept for a second building: expansion beyond the current program, more pressing or finished goods (scale.md). Switch phase 2 on, or add presses beyond the ~870 m2 hall.");
sc.tag((P2.x0 + P2.x1) / 2, (P2.y0 + P2.y1) / 2, 0.5, "Phase 2 reserve", 1, "phase2");
}
vehicles(sc, cfg);
// PV info and roof tags
var ppa = cfg.solar === "ppa", onRoof = pv.by.roofW.panels.length + pv.by.roofE.panels.length,
onCp = pv.by.cars.panels.length + pv.by.scoot.panels.length, onP2 = pv.by.p2 ? pv.by.p2.panels.length : 0,
onOf = pv.by.offW.panels.length + pv.by.offM.panels.length + pv.by.offE.panels.length;
if (cfg.solar !== "none" && pv.want > 0) {
var kwp = cfg.pvKwp, parts = [onRoof + " on the factory roof"];
if (onOf) parts.push(onOf + " on the office roof");
if (onCp) parts.push(onCp + " on the parking canopies");
if (onP2) parts.push(onP2 + " on the phase 2 roof");
sc.inf("pv", "PV " + fmt(kwp) + " kWp, " + fmt(cfg.batteryKwh) + " kWh battery" + (ppa ? " (PPA)" : ""),
fmt(pv.want) + " modules of ~" + PV_MOD.toFixed(2) + " kWp (~2 m2 each): " + parts.join(", ") + (pv.left ? "; " + fmt(pv.left) + " do not fit the roofs (ground mount)" : "") +
". About " + fmt(kwp * 1.4) + " to " + fmt(kwp * 1.5) + " MWh a year at 1,400 to 1,500 kWh/kWp (scale.md), ~87 % of it used on site (zero export)" + (ppa ? ". PPA: the EPC owns the panels, the plant buys the power." : "."));
if (!inside && onRoof) {
sc.tag(X(52), Y(10), roofZ(10) + 1.5, "PV " + fmt(kwp) + " kWp", 10, "pv");
if (ppa) sc.tag(X(80), Y(6), roofZ(6) + 1.0, "PPA panels", 100, "pv", "iso-signtag");
}
if (onCp) sc.tag(32, 9.5, 5.2, "PV canopy", 5, "pv");
if (pv.left) sc.tag(X(118), Y(6), inside ? 9 : roofZ(6) + 1.5, "+" + fmt(pv.left) + " modules need ground mount", 99, "pv", "iso-warntag");
}
if (!inside) {
sc.tag(X(20), Y(12), roofZ(12) + 1.4, "Galvanics", 5, "galvanics");
sc.tag(X(66), Y(16), roofZ(16) + 1.4, "Press hall", 8, "presshall");
sc.tag(X(99), Y(14), roofZ(14) + 1.4, "Finishing", 5, "packaging");
sc.tag(X(117), Y(14), roofZ(14) + 1.4, "Warehouse", 5, "warehouse");
sc.tag(X(6), Y(16), roofZ(16) + 1.4, "Workshop", 5, "workshop");
if (cfg.corridor) sc.tag(X(30), Y(0), FFL + 3.2, "Visitor corridor", 4, "corridor");
}
sc.tag(114, 2.5, 3.8, "Gate", 4, "gate");
sc.tag(32, 20, 1.5, "Car park", 4, "parking");
sc.tag(84, 21, 5, "Fire water", 4, "firewater");
if (al.lost) sc.tag(75, 44, 9, "+" + al.lost + " presses not drawn", 99, "phase2", "iso-warntag");
// render
var spr = sortSprites(sc.sp), h = [], i, s, sb = [1e9, 1e9, -1e9, -1e9];
var slab = [[-8, -24], [158, -24], [158, 126], [-8, 126]];
slab.forEach(function (p) { [0, -3].forEach(function (z) { var q = P.s(p[0], p[1], z); sb[0] = Math.min(sb[0], q[0]); sb[1] = Math.min(sb[1], q[1]); sb[2] = Math.max(sb[2], q[0]); sb[3] = Math.max(sb[3], q[1]); }); });
h.push('<defs><radialGradient id="iso-pool-' + uid + '"><stop offset="0" class="iso-pool0"/><stop offset="1" class="iso-pool1"/></radialGradient></defs>');
h.push('<g class="iso-ground">' + sc.ground.join("") + "</g><g class=\"iso-sprites\">");
for (i = 0; i < spr.length; i++) {
s = spr[i];
if (!s.h.length) continue;
h.push("<g" + did(s.id) + (s.cls ? ' class="' + s.cls + '"' : "") + ">" + s.h.join("") + "</g>");
if (s.sb[1] < sb[1]) sb[1] = s.sb[1];
}
h.push("</g>");
// tags: signs always, names when labels are on
var tags = sc.tags.filter(function (t) { return t.t && (t.k || cfg.labels); });
h.push('<g class="iso-tags">');
tags.forEach(function (t) {
var q = P.s(t.x, t.y, t.z), w = Math.round(t.t.length * 5.6 + 12), hh = 15, d;
d = "M" + (-w / 2) + " -21h" + w + "v" + hh + "h" + (-(w / 2 - 4)) + "l-4 5l-4 -5h" + (-(w / 2 - 4)) + "z";
h.push('<g class="iso-tag' + (t.k ? " " + t.k : "") + '"' + did(t.id) + ' data-p="' + t.p + '" data-w="' + w + '" data-x="' + r1(q[0]) + '" data-y="' + r1(q[1]) +
'" style="transform:translate(' + r1(q[0]) + "px," + r1(q[1]) + 'px) scale(var(--iso-k,1))"><path d="' + d + '"/><text y="-10">' + esc(t.t) + "</text></g>");
});
h.push("</g>");
var html = h.join("");
return { html: html, info: sc.info, bbox: { x: sb[0], y: sb[1] - 30, w: sb[2] - sb[0], h: sb[3] - sb[1] + 30 },
sprites: sc.sp.length, alloc: al, pv: pv, yard: y, focus: P.s(X(64), Y(12), 3) };
}
// ------------------------------------------------------------ mount
var UID = 0;
var NOTE = "Schematic plant view after the Payap scenario model and the utility yard of scale.md; positions and envelopes are estimates. GF, 1F and Roof switch between the ground floor, the upper floors and the roofs. Drag to pan, pinch or Ctrl + wheel to zoom, tap an object for its figures.";
function mount(el, cfg, opts) {
if (!el) throw new Error("CNXIso.mount: no container");
opts = opts || {};
var st = { raw: Object.assign({}, cfg || {}), cfg: norm(cfg), level: levelOf(opts.level || opts.mode, "roof"), rot: opts.rotation === undefined ? 1 : ((opts.rotation | 0) % 4 + 4) % 4, sel: null, uid: ++UID };
var wrap = document.createElement("div");
wrap.className = "iso-wrap";
wrap.innerHTML = '<div class="iso-stage"><svg class="iso-svg" xmlns="http://www.w3.org/2000/svg" role="img" tabindex="0" aria-label="Isometric view of the CNX Vinyl plant"></svg>' +
'<div class="iso-bar" role="toolbar" aria-label="Plant view">' +
'<span class="iso-seg" role="group" aria-label="Level"><button type="button" data-a="gf" title="Ground floor (cutaway)">GF</button>' +
'<button type="button" data-a="1f" title="Upper floors: office first floor, print block at +3.8 m">1F</button>' +
'<button type="button" data-a="roof" title="Roofs and PV">Roof</button></span>' +
'<button type="button" data-a="rotl" title="Rotate left" aria-label="Rotate the view left">&#x21BA;</button>' +
'<button type="button" data-a="rotr" title="Rotate right" aria-label="Rotate the view right">&#x21BB;</button>' +
'<span class="iso-compass" title="North" aria-hidden="true"><svg viewBox="-10 -10 20 20"><g class="iso-needle"><path d="M0 -8L3 3L0 1.4L-3 3Z"/></g></svg><b>N</b></span>' +
'<button type="button" data-a="zout" title="Zoom out" aria-label="Zoom out">&#x2212;</button>' +
'<button type="button" data-a="zin" title="Zoom in" aria-label="Zoom in">+</button>' +
'<button type="button" data-a="fit" title="Show the whole site" aria-label="Fit the whole site">Fit</button></div>' +
'<div class="iso-tip" role="status" hidden></div></div>' + (opts.note === false ? "" : '<p class="iso-note">' + NOTE + "</p>");
el.appendChild(wrap);
var stage = wrap.querySelector(".iso-stage"), svg = wrap.querySelector(".iso-svg"), tip = wrap.querySelector(".iso-tip"), bar = wrap.querySelector(".iso-bar");
var scn = null, vb = null, fitW = 1, atFit = true, metas = [], declT = 0, alive = true, lastSize = [0, 0];
function size() { var r = stage.getBoundingClientRect(); return [Math.max(1, r.width), Math.max(1, r.height)]; }
function build() {
scn = buildScene(st.cfg, st.level, st.rot, st.uid);
svg.innerHTML = scn.html;
wrap.classList.toggle("iso-still", !st.cfg.running);
wrap.classList.toggle("iso-inside", st.level !== "roof");
wrap.classList.toggle("iso-l1", st.level === "1f");
metas = Array.prototype.map.call(svg.querySelectorAll(".iso-tag"), function (g, i) {
return { el: g, p: +g.getAttribute("data-p"), w: +g.getAttribute("data-w"), x: +g.getAttribute("data-x"), y: +g.getAttribute("data-y"), i: i, hid: false };
}).sort(function (a, b) { return b.p - a.p || a.i - b.i; });
Array.prototype.forEach.call(bar.querySelectorAll(".iso-seg button"), function (b) { b.setAttribute("aria-pressed", String(b.getAttribute("data-a") === st.level)); });
var n = ROT[st.rot](0, 1), ang = Math.atan2((n[0] + n[1]) / 2, n[0] - n[1]) * 180 / Math.PI + 90;
wrap.querySelector(".iso-needle").setAttribute("transform", "rotate(" + ang.toFixed(1) + ")");
if (st.sel) select(st.sel, true);
}
function computeFit() {
var b = scn.bbox, sz = size(), ar = sz[0] / sz[1], w = b.w * 1.02, h = b.h * 1.04;
if (w / h > ar) h = w / ar; else w = h * ar;
fitW = w;
return { x: b.x + b.w / 2 - w / 2, y: b.y + b.h / 2 - h / 2, w: w, h: h };
}
function apply() {
if (!vb) return;
svg.setAttribute("viewBox", r1(vb.x) + " " + r1(vb.y) + " " + r1(vb.w) + " " + r1(vb.h));
var sz = size();
svg.style.setProperty("--iso-k", (vb.w / sz[0]).toFixed(4));
atFit = vb.w >= fitW * 0.995;
svg.style.touchAction = atFit ? "pan-y" : "none";
wrap.classList.toggle("iso-zoomed", !atFit);
schedule();
}
function fit() { vb = computeFit(); apply(); }
function clampVB() {
var b = scn.bbox, cx = vb.x + vb.w / 2, cy = vb.y + vb.h / 2;
cx = Math.max(b.x, Math.min(b.x + b.w, cx)); cy = Math.max(b.y, Math.min(b.y + b.h, cy));
vb.x = cx - vb.w / 2; vb.y = cy - vb.h / 2;
}
function zoomAt(f, px, py) { // px, py: 0..1 within the stage
if (!vb) return;
var sz = size(), nw = Math.max(fitW / 14, Math.min(fitW, vb.w / f)), nh = nw * sz[1] / sz[0];
if (px === undefined) { px = 0.5; py = 0.5; }
var ux = vb.x + px * vb.w, uy = vb.y + py * vb.h;
vb = { x: ux - px * nw, y: uy - py * nh, w: nw, h: nh };
if (nw >= fitW * 0.999) vb = computeFit(); else clampVB();
apply();
}
function zoomClient(f, cx, cy) { var r = stage.getBoundingClientRect(); zoomAt(f, (cx - r.left) / r.width, (cy - r.top) / r.height); }
function panPx(dx, dy) { var sz = size(), k = vb.w / sz[0]; vb.x -= dx * k; vb.y -= dy * k; clampVB(); apply(); }
function focusDefault() {
fit();
var sz = size();
if (sz[0] < 640 && scn) {
var f = 2.4, nw = fitW / f, nh = nw * sz[1] / sz[0];
vb = { x: scn.focus[0] - nw / 2, y: scn.focus[1] - nh / 2, w: nw, h: nh };
clampVB(); apply();
}
}
function schedule() {
if (declT) return;
declT = (window.requestAnimationFrame || setTimeout)(function () { declT = 0; declutter(); });
}
function declutter() {
if (!alive || !vb) return;
var sz = size(), k = vb.w / sz[0], kept = [], i, j, t, x, y, r, vis;
var bt = bar.offsetTop + bar.offsetHeight + 2, bw = bar.offsetLeft + bar.offsetWidth;
for (i = 0; i < metas.length; i++) {
t = metas[i]; x = (t.x - vb.x) / k; y = (t.y - vb.y) / k;
r = [x - t.w / 2 - 2, y - 23, x + t.w / 2 + 2, y - 4];
vis = r[0] >= 2 && r[2] <= sz[0] - 2 && r[1] >= 2 && r[3] <= sz[1] - 2 && !(r[1] < bt && r[0] < bw);
for (j = 0; vis && j < kept.length; j++) if (!(r[2] < kept[j][0] || r[0] > kept[j][2] || r[3] < kept[j][1] || r[1] > kept[j][3])) vis = false;
if (vis) kept.push(r);
if (t.hid === vis) { t.hid = !vis; t.el.classList.toggle("iso-hide", !vis); }
}
}
function infoOf(id) { return (scn && scn.info[id]) || { id: id, name: id, detail: "" }; }
function showTip(info, cx, cy) {
tip.innerHTML = "<b>" + esc(info.name) + "</b>" + (info.detail ? "<span>" + esc(info.detail) + "</span>" : "");
tip.hidden = false;
var r = stage.getBoundingClientRect(), tw = tip.offsetWidth, th = tip.offsetHeight, x = cx - r.left + 14, y = cy - r.top + 14;
if (x + tw > r.width - 6) x = Math.max(6, cx - r.left - tw - 14);
if (y + th > r.height - 6) y = Math.max(6, cy - r.top - th - 14);
tip.style.left = x + "px"; tip.style.top = y + "px";
}
function hideTip() { tip.hidden = true; }
function select(id, quiet) {
Array.prototype.forEach.call(svg.querySelectorAll(".iso-sel"), function (e) { e.classList.remove("iso-sel"); });
st.sel = id || null;
if (!id) return;
Array.prototype.forEach.call(svg.querySelectorAll('[data-id="' + String(id).replace(/"/g, "") + '"]'), function (e) { if (!e.classList.contains("iso-tag")) e.classList.add("iso-sel"); });
}
function targetId(t) { var e = t && t.closest ? t.closest("[data-id]") : null; return e && svg.contains(e) ? e.getAttribute("data-id") : null; }
function pick(t, cx, cy) {
var id = targetId(t);
if (!id) { select(null); hideTip(); return; }
var info = infoOf(id);
select(id);
showTip(info, cx, cy);
if (typeof opts.onPick === "function") { try { opts.onPick({ id: info.id, name: info.name, detail: info.detail }); } catch (e) { if (window.console) console.error(e); } }
}
// pointer: hover tips, drag pan, pinch zoom, tap pick
var ptrs = {}, down = null, moved = false, pinch = null, hoverId = null;
function npt() { return Object.keys(ptrs).length; }
svg.addEventListener("pointerdown", function (e) {
ptrs[e.pointerId] = { x: e.clientX, y: e.clientY };
if (npt() === 1) { down = { x: e.clientX, y: e.clientY, id: e.pointerId, t: e.target }; moved = false; }
if (npt() === 2) {
var k = Object.keys(ptrs), a = ptrs[k[0]], b = ptrs[k[1]];
pinch = { d: Math.hypot(a.x - b.x, a.y - b.y) || 1 }; moved = true;
}
});
svg.addEventListener("pointermove", function (e) {
var p = ptrs[e.pointerId];
if (p) {
var dx = e.clientX - p.x, dy = e.clientY - p.y;
p.x = e.clientX; p.y = e.clientY;
if (pinch && npt() >= 2) {
var k = Object.keys(ptrs), a = ptrs[k[0]], b = ptrs[k[1]], d = Math.hypot(a.x - b.x, a.y - b.y) || 1;
zoomClient(d / pinch.d, (a.x + b.x) / 2, (a.y + b.y) / 2); pinch.d = d; return;
}
if (down && !moved && Math.hypot(e.clientX - down.x, e.clientY - down.y) > 5) {
moved = true; hideTip(); svg.classList.add("iso-dragging");
try { svg.setPointerCapture(e.pointerId); } catch (er) { /* ignore */ }
}
if (moved) { panPx(dx, dy); e.preventDefault(); }
return;
}
if (e.pointerType === "mouse") {
var id = targetId(e.target);
if (id) {
if (id !== hoverId) { if (hoverId) setHover(hoverId, false); setHover(id, true); hoverId = id; }
showTip(infoOf(id), e.clientX, e.clientY);
} else if (hoverId) { setHover(hoverId, false); hoverId = null; if (!st.sel) hideTip(); }
}
});
function setHover(id, on) {
Array.prototype.forEach.call(svg.querySelectorAll('[data-id="' + id + '"]'), function (e) { if (!e.classList.contains("iso-tag")) e.classList.toggle("iso-hov", on); });
}
function up(e) {
var wasDown = down && down.id === e.pointerId;
delete ptrs[e.pointerId];
if (npt() < 2) pinch = null;
if (wasDown && !moved && e.type === "pointerup") pick(e.target, e.clientX, e.clientY);
if (npt() === 0) { down = null; svg.classList.remove("iso-dragging"); }
}
svg.addEventListener("pointerup", up);
svg.addEventListener("pointercancel", up);
svg.addEventListener("pointerleave", function (e) { if (e.pointerType === "mouse" && hoverId) { setHover(hoverId, false); hoverId = null; if (!st.sel) hideTip(); } });
svg.addEventListener("wheel", function (e) {
if (!(e.ctrlKey || e.metaKey)) return;
e.preventDefault();
zoomClient(Math.exp(-e.deltaY * 0.004), e.clientX, e.clientY);
}, { passive: false });
svg.addEventListener("dblclick", function (e) { e.preventDefault(); zoomClient(1.8, e.clientX, e.clientY); });
svg.addEventListener("keydown", function (e) {
var k = e.key;
if (k === "+" || k === "=") zoomAt(1.4); else if (k === "-") zoomAt(1 / 1.4); else if (k === "0") fit();
else if (k === "ArrowLeft") panPx(60, 0); else if (k === "ArrowRight") panPx(-60, 0); else if (k === "ArrowUp") panPx(0, 60); else if (k === "ArrowDown") panPx(0, -60);
else if (k === "Escape") { select(null); hideTip(); } else return;
e.preventDefault();
});
bar.addEventListener("click", function (e) {
var b = e.target.closest ? e.target.closest("button") : null;
if (!b) return;
var a = b.getAttribute("data-a");
if (a === "gf" || a === "1f" || a === "roof") setLevel(a);
else if (a === "rotl") rotate(-1); else if (a === "rotr") rotate(1);
else if (a === "zin") zoomAt(1.5); else if (a === "zout") zoomAt(1 / 1.5); else if (a === "fit") fit();
});
// levels: "gf" | "1f" | "roof"; the legacy modes map "inside" to GF and "roof" to Roof
function legacyMode(l) { return l === "roof" ? "roof" : "inside"; }
function setLevel(l) {
l = levelOf(l, null);
if (!l || l === st.level) return;
var om = legacyMode(st.level);
st.level = l; build(); computeFit(); apply();
if (typeof opts.onLevel === "function") { try { opts.onLevel(l); } catch (e) { if (window.console) console.error(e); } }
if (typeof opts.onMode === "function" && legacyMode(l) !== om) { try { opts.onMode(legacyMode(l)); } catch (e) { if (window.console) console.error(e); } }
}
function setMode(m) { setLevel(levelOf(m, "roof")); }
function rotate(d) {
var keepZoom = vb ? vb.w / fitW : 1;
st.rot = ((st.rot + (d || 1)) % 4 + 4) % 4; build(); hideTip();
if (keepZoom >= 0.995) fit();
else { computeFit(); var sz = size(), nw = fitW * keepZoom, nh = nw * sz[1] / sz[0]; vb = { x: scn.focus[0] - nw / 2, y: scn.focus[1] - nh / 2, w: nw, h: nh }; clampVB(); apply(); }
}
var ro = null, io = null;
function onResize() {
var sz = size();
if (Math.abs(sz[0] - lastSize[0]) < 0.5 && Math.abs(sz[1] - lastSize[1]) < 0.5) return;
var old = lastSize; lastSize = sz;
if (!vb || !old[0]) { focusDefault(); return; }
var wasFit = atFit, k = vb.w / old[0], cx = vb.x + vb.w / 2, cy = vb.y + vb.h / 2;
computeFit();
if (wasFit) { fit(); return; }
var nw = Math.min(fitW, k * sz[0]), nh = nw * sz[1] / sz[0];
vb = { x: cx - nw / 2, y: cy - nh / 2, w: nw, h: nh }; clampVB(); apply();
}
if (window.ResizeObserver) { ro = new ResizeObserver(onResize); ro.observe(stage); }
else window.addEventListener("resize", onResize);
if (window.IntersectionObserver) {
io = new IntersectionObserver(function (es) { es.forEach(function (en) { wrap.classList.toggle("iso-off", !en.isIntersecting); }); });
io.observe(stage);
}
build();
lastSize = size();
focusDefault();
var view = {
el: wrap,
svg: svg,
update: function (c) { st.raw = Object.assign({}, st.raw, c || {}); st.cfg = norm(st.raw); build(); var wasFit = atFit; computeFit(); if (wasFit) fit(); else { clampVB(); apply(); } return view; },
setMode: function (m) { setMode(m); return view; },
getMode: function () { return legacyMode(st.level); },
setLevel: function (l) { setLevel(l); return view; },
getLevel: function () { return st.level; },
rotate: function (d) { rotate(d); return view; },
setRotation: function (r) { var d = (((r | 0) - st.rot) % 4 + 4) % 4; if (d) rotate(d); return view; },
select: function (id) { select(id); return view; },
// centre the view on an object id (or a world point [x, y, z]) at a zoom factor relative to fit
focus: function (target, f) {
var c = null;
if (Array.isArray(target)) c = new Proj(st.rot).s(target[0], target[1], target[2] || 0);
else if (target) {
var bx = [1e9, 1e9, -1e9, -1e9];
Array.prototype.forEach.call(svg.querySelectorAll('[data-id="' + String(target).replace(/"/g, "") + '"]'), function (e) {
if (e.classList.contains("iso-tag")) return;
try { var b = e.getBBox(); if (b.width || b.height) { bx[0] = Math.min(bx[0], b.x); bx[1] = Math.min(bx[1], b.y); bx[2] = Math.max(bx[2], b.x + b.width); bx[3] = Math.max(bx[3], b.y + b.height); } } catch (er) { /* not rendered */ }
});
if (bx[0] < 1e9) c = [(bx[0] + bx[2]) / 2, (bx[1] + bx[3]) / 2];
}
if (!c) return view;
computeFit();
var sz = size(), nw = fitW / Math.max(1, f || 3), nh = nw * sz[1] / sz[0];
vb = { x: c[0] - nw / 2, y: c[1] - nh / 2, w: nw, h: nh }; clampVB(); apply();
return view;
},
zoom: function (f) { zoomAt(f || 1.5); return view; },
fit: function () { fit(); return view; },
info: function (id) { return id ? infoOf(id) : Object.keys(scn.info).map(infoOf); },
config: function () { return Object.assign({}, st.cfg); },
stats: function () { return { nodes: svg.querySelectorAll("*").length, sprites: scn.sprites, level: st.level, presses: scn.alloc, pv: { want: scn.pv.want, placed: scn.pv.placed, left: scn.pv.left } }; },
destroy: function () {
alive = false;
if (ro) ro.disconnect(); else window.removeEventListener("resize", onResize);
if (io) io.disconnect();
if (wrap.parentNode) wrap.parentNode.removeChild(wrap);
}
};
return view;
}
function copyRects(o) { var r = {}, k; for (k in o) r[k] = o[k].slice(); return r; }
root.CNXIso = { mount: mount, defaults: function () { return Object.assign({}, DEF); }, normalize: norm, version: VERSION,
levels: ["gf", "1f", "roof"],
// site data for checks: lamp positions and the paved drive areas they must stay off
site: function () { return { lamps: LAMPS.map(function (p) { return p.slice(); }), paved: copyRects(PAVED) }; },
_build: function (cfg, mode, rot) { return buildScene(norm(cfg), mode, rot || 0, 0); } };
})(typeof window !== "undefined" ? window : this);
</script>
<script>
"use strict";
// ---------------------------------------------------------------- inputs
const S0 = {
// specials share 60 % (user, October 2026: the plant focuses on special prints, 60 %+)
mode: "target", target: 850000, spec: 60, grow: "classic", dMin: 1, cMin: 1,
dFleet: 1, cFleet: 1, shifts: 2, hrs: 8, days: 300, eff: 65, rateD: 3, rateC: 2, specF: 100,
priceStd: 3.8, premSpec: 1.15, titleFee: 1155, cur: "USD", boi: "on", exportShare: 70, capexEnd: "mid",
// plant configuration (defaults = the launch plant, total-investment.md October 2026)
galv: "in", galvPrice: "pheenix", cut: "in", metro: "full", cass: "on", cdr: "on", countCD: "off",
solar: "buy", steamFuel: "biomass", sourcing: "thai", crane: "buy", corridor: "now", reserve: "in", cont: "in", stSupply: 0,
};
// allowed values of every switch, default first where it matters for readHash
const ENUM = {
mode: ["target", "fleet"], grow: ["classic", "duplex"], cur: ["USD", "THB", "EUR"], boi: ["on", "off"],
capexEnd: ["low", "mid", "high"], galv: ["in", "bought"], galvPrice: ["pheenix", "oms"], cut: ["in", "out"],
metro: ["full", "launch"], cass: ["on", "off"], cdr: ["on", "off"], countCD: ["off", "on"],
solar: ["buy", "ppa", "none"], steamFuel: ["electric", "biomass"], sourcing: ["thai", "china"],
crane: ["buy", "rent"], corridor: ["now", "later"], reserve: ["in", "sep"], cont: ["in", "sep"],
};
const CFGK = ["galv", "galvPrice", "cut", "metro", "cass", "cdr", "countCD", "solar", "steamFuel", "sourcing", "crane", "corridor", "reserve", "cont", "stSupply"];
// [group, id, label, value, unit, kind, source]
const AS = [];
function grp(g, rows) { rows.forEach(r => AS.push([g].concat(r))); }
// a low-to-high pair, shown in one row of the assumptions table: [id, label, lo, hi, unit, kind, source]
function rng(g, rows) { rows.forEach(([id, l, lo, hi, u, k, s]) => { AS.push([g, id + "Lo", l, lo, u, k, s, 1]); AS.push([g, id + "Hi", l, hi, u, k, s, 2]); }); }
grp("Personnel", [
["opD", "Operators per Duplex system, per shift", 1, "", "doc", "Duplex cell layout: one operator in the middle of each cell (pressing-line.md)"],
["cPerAtt", "Classics one attendant looks after", 4, "", "est", "Assumption: Newbilt has not stated it"],
["finRate", "Finishing output per person", 200, "records/h", "est", "Inspection, sleeving, packing; in line with scale.md (4 to 6 press and finishing people per shift for the start fleet)"],
["galvRate", "Electroformed parts per galvanics operator", 20, "a day", "vendor", "OMS rule of thumb: one person runs about 20 stampers a day (galvanics.md)"],
]);
grp("Per press (HRP 12; a Classic counts as one)", [
["pressKVA", "Connected load", 1, "kVA", "vendor", "Newbilt Duplex site requirements 3.2.1"],
["pressKW", "Average power", 0.5, "kW", "vendor", "Site requirements 3.2.1"],
["steamLo", "Steam, low", 150, "kg/h", "vendor", "Site requirements 3.2.3: 150 to 180 kg/h, 10 bar, 180 °C"],
["steamHi", "Steam, high", 180, "kg/h", "vendor", "Site requirements 3.2.3"],
["coolPress", "Cooling capacity, peak", 125, "kW", "vendor", "Site requirements 3.2.2: up to 125 kW depending on cycle time"],
["waterTyp", "Cooling water, typical", 53, "l/min", "vendor", "Site requirements 3.2.2: about 53 l/min used"],
["waterPeak", "Cooling water, peak", 100, "l/min", "vendor", "Site requirements 3.2.2: up to 100 l/min"],
["airPress", "Compressed air", 6, "m³/h", "vendor", "Site requirements 3.2.3: 6 to 10 bar"],
]);
grp("Per extruder (EX 50; EX 35 and Classic extruder at the same figures)", [
["extKVA", "Connected load", 15, "kVA", "vendor", "Site requirements 3.3.1 (EX 35 and Classic: estimate)"],
["extKW", "Average power", 8, "kW", "vendor", "Site requirements 3.3.1"],
["extAir", "Compressed air", 2, "m³/h", "vendor", "Site requirements 3.3.2"],
["extWater", "Cooling water", 4, "l/min", "vendor", "Site requirements 3.3.3"],
["extCool", "Cooling capacity", 3, "kW", "vendor", "Site requirements 3.3.3: about 2 to 3 kW"],
]);
grp("Central hydraulics, per press (Newbilt's aggregate serves two)", [
["hydFlow", "Pump flow", 16, "l/min", "vendor", "Offer 240620-04: 32 l/min pump per aggregate"],
["hydMotor", "Installed motor power", 7.5, "kW", "vendor", "Offer 240620-04: 11/15 kW motor per aggregate"],
["hydKVA", "Connected load", 7.5, "kVA", "vendor", "Site requirements 3.4.1: 15 kVA per aggregate"],
["hydKW", "Average power", 6, "kW", "vendor", "Site requirements 3.4.1: 12 kW per aggregate"],
["hydOil", "Oil fill (HLP 46)", 100, "l", "vendor", "Site requirements 3.4.3: 200 l per aggregate, 250 l tank"],
["hydAcc", "Accumulator volume", 25, "l", "vendor", "Offer 240620-04: 50 l per aggregate, up to 200 bar"],
["hydWater", "Oil cooler water", 4, "l/min", "vendor", "Site requirements 3.4.2: about 8 l/min per aggregate"],
["hydCool", "Oil cooler capacity", 2.5, "kW", "vendor", "Site requirements 3.4.2: up to 5 kW per aggregate"],
]);
grp("Utilities to supply it", [
["cop", "Chiller COP", 4, "", "est", "Estimate for a water-cooled chiller"],
["compSpec", "Compressor specific power", 0.11, "kW per m³/h", "est", "Estimate: screw compressor at about 6.5 kW per m³/min"],
]);
grp("Floor", [
["floorD", "Press floor per Duplex system", 110, "m²", "doc", "Payap model bay, 9.5 × 11.4 m; Newbilt recommends about 200 m² including storage and side rooms"],
["floorC", "Press floor per Classic", 50, "m²", "est", "Payap model position, 5.5 × 9 m; pressing-line.md estimates 50 to 75 m²"],
]);
grp("Material", [
["weight", "Average record weight", 160, "g", "est", "Estimate across the 140 and 180 g formats"],
["trim", "Edge trim per record", 30, "g", "vendor", "Newbilt granulator flyer: 20 to 40 g per 12-inch record, back as regrind"],
["reject", "Rejects", 5, "%", "est", "Estimate; Newbilt claims at least 95 % yield for the Classic"],
]);
grp("Stampers", [
["runLen", "Records per title", 3000, "", "doc", "Revenue model: 2,500 to 3,300 records per title"],
["stLife", "Pressings per stamper", 1500, "", "doc", "galvanics.md: 1,000 to 2,000"],
]);
grp("Mastering, galvanics and stamper prep", [
["lathe", "Lacquer sides one lathe cuts a day", 8, "sides", "est", "Estimate: about 20 min cutting per side plus setup and checks, one shift"],
["galvEff", "Usable share of galvanics and prep hours", 80, "%", "est", "Estimate; OMS: 6 to 8 runs on 4 cells in a 7.5-hour day (galvanics.md). Galvanics runs the plant's shifts"],
["silverMin", "Silvering per lacquer (PASU)", 15, "min", "est", "galvanics.md: ~15 min, estimate"],
["preMin", "Pre-plate per master (PPPU, four at once)", 25, "min", "doc", "galvanics.md: 15 to 30 min"],
["fatherMin", "Father in a PDPU cell", 75, "min", "doc", "galvanics.md: ~1 h 15 min"],
["motherMin", "Mother in a PDPU cell", 70, "min", "doc", "galvanics.md: ~1 to 1 1/4 h"],
["stamperMin", "Stamper in a PDPU cell", 45, "min", "doc", "galvanics.md: ~45 min"],
["handleMin", "Loading, rinsing, unloading per part", 15, "min", "doc", "galvanics.md: ~15 min on top of deposition"],
["pmpbMin", "Separation, passivation, cleaning per part (PMPB)", 12, "min", "est", "galvanics.md: ~10 to 15 min, estimate"],
["vsfMin", "Back-finish per stamper (Sibert VSF)", 8, "min", "est", "galvanics.md: 5 to 10 min, estimate; Sibert publishes none"],
["vspMin", "Centre punch per stamper (Sibert VSP)", 4, "min", "est", "galvanics.md: 3 to 5 min, estimate"],
["vscMin", "Coin per stamper (Sibert VSC)", 2, "min", "est", "galvanics.md: 1 to 2 min, estimate"],
]);
grp("Money", [
["thbUsd", "THB per USD", 33, "THB", "doc", "revenue-model.md and total-investment.md, FX of 5 August 2026"],
["usdEur", "USD per EUR", 1.155, "USD", "doc", "revenue-model.md"],
["overheads", "Cash overheads a year", 330000, "USD", "doc", "revenue-model.md: 200 to 450k (admin, sales, insurance, facility, IT, fees, marketing)"],
["deprYears", "Depreciation period", 12.5, "years", "doc", "revenue-model.md: 10 to 15 years"],
]);
grp("BOI and imports", [
["dutyMach", "Import duty on imported machinery without BOI", 5, "%", "est", "Estimate: Thai MFN averages 4.1 % on HS 8477 plastics machinery (customs-compliance.ai, March 2026), 0 to 10 % across HS 84; set per line once the HS codes are known. Imported machinery = the selected presses, galvanics line, Sibert, cutting room, metrology and ZLD/DI blocks"],
["vatRate", "VAT", 7, "%", "doc", "Thai VAT; with BOI duty exemption the import VAT is exempt too (Revenue Department, BOI OSOS FAQ; boi.md)"],
["pvcImp", "Share of PVC compound imported", 100, "%", "est", "Estimate: record-grade compound from the specialist suppliers"],
["dutyPVC", "Import duty on PVC compound without BOI", 5, "%", "est", "Estimate; no Thai tariff line checked for record compound yet"],
["boiRawRed", "BOI duty reduction on raw material for domestic sales", 90, "%", "doc", "boi.md: exempt for export production (section 36(1)), up to 90 % off for domestic sales (section 30)"],
]);
grp("Materials and packaging", [
["pvcPrice", "PVC compound", 2.3, "USD/kg", "doc", "revenue-model.md: 1.75 to 2.90 USD/kg record grade"],
["labelCost", "Labels, a pair", 0.06, "USD", "doc", "revenue-model.md: 0.03 to 0.09"],
["sleeveCost", "Inner sleeve", 0.13, "USD", "doc", "revenue-model.md: 0.09 to 0.17, poly-lined"],
["jacketCost", "Jacket (printed carton)", 0.66, "USD", "doc", "revenue-model.md: 0.40 to 0.92, local carton partners"],
["shrinkCost", "Shrink wrap", 0.025, "USD", "doc", "revenue-model.md: 0.02 to 0.03"],
["cartonCost", "Shipping cartons and pallets, a record", 0.04, "USD", "est", "Estimate: a 25-record shipping carton at about 1 USD"],
["maintCost", "Maintenance, spares, consumables, a record", 0.17, "USD", "doc", "revenue-model.md: 0.12 to 0.23"],
]);
grp("Stamper making", [
["lacquerCost", "Lacquer blank, one per side", 70, "EUR", "user", "Team figure, October 2026 (revenue-model.md); styli not included"],
["chemPart", "Nickel, silver, chemistry, an electroformed part", 25, "USD", "doc", "revenue-model.md: 60 to 150 USD marginal per three-generation set, so 20 to 50 a part"],
]);
grp("Energy", [
["kwhPrice", "Electricity, blended", 3.81, "THB/kWh", "doc", "scale.md: PEA TOU 3.2.2 at two shifts, before the demand charge (Ft September to December 2026)"],
["demandPrice", "Demand charge", 132.93, "THB/kW a month", "doc", "PEA 3.2.2 (scale.md)"],
["restKW", "Rest of the plant while running", 120, "kW", "est", "Estimate: HVAC, lighting, galvanics, finishing, offices; puts the start fleet in scale.md's 4.5 to 6.8M THB a year before PV"],
["pvKWp", "PV array on the factory roof", 520, "kWp", "est", "Decided October 2026: PV on the factory only, the office roof (~65 kWp) and the parking canopies (~54 kWp) PV-ready for later. Payap scenario factory roof ~523 kWp (site-candidates.md parts model, 580 W placeholder module), near the biomass-case optimum of ~500 kWp (sustainability.md); the Mueang Chi roof takes ~247 kWp (scale.md)"],
["pvYield", "PV yield", 1400, "kWh/kWp", "doc", "scale.md: 1,400 to 1,500 kWh/kWp a year in northern Thailand"],
["pvSelf", "Share of the PV yield used on site", 87, "%", "est", "Hourly sketch (October 2026), biomass steam: ~230 kW running load on two shifts and 300 days, ~60 kW off-shift base load, the 271 kWh battery; the rest is curtailed, because a factory on PEA's grid runs zero export (sustainability.md)"],
["heatPrice", "Steam heat from biomass", 0.7, "THB/kWh", "doc", "biomass.md: 0.5 to 0.9 at 1.35 to 2 THB/kg delivered"],
["steamKWh", "Heat per kg of steam", 0.68, "kWh/kg", "est", "Estimate: 10 bar saturated steam from ~80 °C feed water"],
["eBoilEff", "Electric boiler efficiency", 98, "%", "est", "Estimate for an electric steam boiler (~98 %)"],
["eBoilKW", "Electric boiler demand per installed module", 360, "kW", "est", "Estimate: a 500 kg/h electric module at full load; the demand charge counts every installed module, so it errs high"],
["ppaPrice", "Solar PPA power price", 3.0, "THB/kWh", "est", "Estimate, to quote: the EPC owns and finances the array, the plant buys its yield (total-investment.md investor levers)"],
]);
grp("Labour: base salaries, THB a month", [
["salOp", "Press and finishing operator", 15000, "THB", "doc", "revenue-model.md: ~19,500 loaded at ×1.3"],
["salQC", "QC", 20000, "THB", "doc", "revenue-model.md: ~26,000 loaded"],
["salMaint", "Maintenance", 25000, "THB", "doc", "revenue-model.md: ~32,500 loaded"],
["salGalv", "Galvanics", 25000, "THB", "doc", "revenue-model.md: ~32,500 loaded"],
["salCut", "Cutting engineer", 50000, "THB", "user", "Team figure, October 2026 (revenue-model.md)"],
["officeHeads", "Admin, sales and logistics staff", 4, "people", "doc", "scale.md: 3 to 5; their wages sit in the overheads, they count here for health care"],
["qcPerShift", "QC inspectors per shift", 1, "", "est", "Estimate; scale.md: 1 to 2 QC"],
["pressPerTech", "Presses per maintenance technician (at least 2)", 8, "", "est", "Estimate; scale.md: 2 in maintenance for the start fleet"],
]);
grp("Labour: on-costs and health care", [
["ssRate", "Employer social security", 5, "% of salary", "doc", "Social Security Act, section 33: 5 % up to the wage ceiling (worker-safety.md)"],
["ssCap", "Social security wage ceiling", 17500, "THB a month", "doc", "Raised from 15,000 on 1 January 2026, so at most 875 THB a month (worker-safety.md)"],
["otherOn", "Overtime, bonus, PPE and other on-costs", 25, "% of salary", "doc", "With social security this is the revenue model's ×1.3 at 15,000 THB"],
["healthIns", "Group health insurance (inpatient and outpatient)", 15000, "THB a head a year", "doc", "worker-safety.md: AXA SME plans 9,394 to 18,006 THB at 25 staff (CheckDi, October 2026); local plans 8,000 to 12,000"],
["checkup", "Annual health check-up", 2500, "THB a head a year", "est", "Estimate for an age mix; Rajavej Chiang Mai 2026 programmes 1,290 to 5,990 THB by age (worker-safety.md)"],
["occCheck", "Risk-factor tests (hearing, lungs, metals)", 1500, "THB a head a year", "est", "Estimate; required yearly for press floor, galvanics and maintenance (Ministerial Regulation B.E. 2547, worker-safety.md)"],
]);
grp("Workwear: Engelbert Strauss for all staff", [
["wwProd", "Production kit, with logo", 338, "EUR", "doc", "worker-safety.md: 3 trousers e.s.motion 2020, 3 polos, 2 T-shirts, sweatshirt, S1 safety shoes; Strauss prices without German VAT, October 2026; embroidery ~5 EUR a piece (estimate)"],
["wwOffice", "Office kit, with logo", 127, "EUR", "doc", "worker-safety.md: 4 polo shirts and a softshell jacket"],
["wwProdYr", "Production kits a head a year", 1, "", "est", "Estimate: daily wear and washing"],
["wwOffYr", "Office kits a head a year", 0.5, "", "est", "Estimate"],
["wwFreight", "Shipping to Thailand", 15, "% of goods", "est", "Estimate; Strauss quotes shipping with the order confirmation"],
["wwDuty", "Thai import duty on workwear", 14, "%", "doc", "HS 6203 work trousers 13.8 % (customs-compliance.ai, March 2026); BOI does not cover clothing; import VAT reclaimable"],
]);
grp("Planned plant, for the checks", [
["boilerMod", "Steam boiler module", 500, "kg/h", "doc", "scale.md utility phasing and steam-cooling.html: 500 kg/h modules, B-1 and B-2 at launch (proposal, estimate)"],
["chillerMod", "Chiller module", 250, "kW", "doc", "scale.md launch plant: 250 kW air-cooled modules at 40 °C, CH-1 and CH-2 at launch (proposal, estimate)"],
["modRooms", "Modules the boiler hall and the chiller compound are built for", 4, "each", "doc", "Utility yard decided October 2026: rooms for four boiler and four chiller modules (scale.md)"],
["hall", "Payap press hall", 870, "m²", "doc", "site-candidates.md, Payap scenario model"],
]);
grp("Title services and add-on lines", [
["titleContrib", "Contribution on title services done in-house", 65, "%", "doc", "revenue-model.md: 60 to 70 % after lacquers, styli and nickel (adverse 60, central 65, favourable 70)"],
["shareCut", "Share of the title fee: mastering, lacquer and cutting", 51.1, "%", "est", "Artist demos' per-title price 37,836 THB: mastering 10,000 + lacquers 5,336 + cutting 4,000 THB (artist-order.html price list, estimates)"],
["sharePlate", "Share of the title fee: electroforming (stampers)", 37.0, "%", "est", "Same price list: father, mother and stamper 14,000 THB of 37,836"],
["shareSetup", "Share of the title fee: press setup and test pressings", 11.9, "%", "est", "Same price list: setup 3,000 + five test pressings 1,500 THB of 37,836"],
["stSupplyC", "Stamper supply to other plants: contribution a title", 267, "USD", "est", "Business plan draft 0.1 and revenue-model.md: 300 titles at wholesale might net 60k to 100k USD, so 200 to 333 a title (adverse 200, favourable 333)"],
["cassN", "Cassettes a year, if counted", 40000, "", "est", "Business plan draft 0.1: illustratively 20k to 60k a year; no market data"],
["cassPrice", "Cassette price", 3.25, "USD", "est", "Business plan draft 0.1: 2.50 to 4.00 USD; no market data"],
["cdrN", "CD-Rs a year, if counted", 25000, "", "est", "Business plan draft 0.1: illustratively 10k to 40k a year; no market data"],
["cdrPrice", "CD-R price", 2.25, "USD", "est", "Business plan draft 0.1: 1.50 to 3.00 USD; no market data"],
]);
rng("Investment blocks (USD unless THB)", [
["p0", "Phase 0: BOI consultant, legal and lease work, soil test, re-quotes", 40000, 80000, "USD", "est", "total-investment.md launch plant, estimate"],
["dup1", "Newbilt Duplex system, the first", 350000, 370000, "USD", "doc", "total-investment.md: 2024 quote EUR 200,500 for the Single 2.1 plus the quoted second press and extruder ~USD 115k; expired, re-quote"],
["dupN", "Each further Duplex system", 230000, 300000, "USD", "est", "total-investment.md added scope: two presses at the quoted ~115k per press and extruder set, plus hydraulic unit and EX 35; no EX 35 price on file"],
["cla", "Each Newbilt Classic", 290000, 460000, "USD", "est", "total-investment.md: placeholder (the old AD12 band per press); no Newbilt price on file"],
["gPh", "Pheenix galvanics line (PASU, PPPU, PDPU, PMPB), OMS reference plus 20 %", 324000, 445000, "USD", "est", "total-investment.md (user, October 2026): OMS's 4-cell wet room with the know-how transfer, EUR 234k to 321k (letter, 9 October 2026), plus 20 %; no Pheenix price on file"],
["gOms", "Galvanics wet room bought from OMS directly", 270000, 371000, "USD", "vendor", "OMS letter, 9 October 2026: EUR 224k basic to 311k advanced plus the EUR 10k know-how transfer, at 1.155 USD; reference prices, not a quote"],
["gWat", "Galvanics water: ZLD evaporator and DI water", 3200000, 8500000, "THB", "est", "Utilities CSV rows ZLD 3.0 to 8.0M and RO/DI 0.2 to 0.5M THB, market estimates"],
["wtb", "Water-treatment building next to galvanics, ~12 × 10 m", 1700000, 2900000, "THB", "est", "total-investment.md: estimate at the building rate"],
["sib", "Sibert VSF, VSP, VSC", 250000, 250000, "USD", "vendor", "Sibert's budget confirmation, 5 October 2026; its time, spares, shipping and travel in the freight and working-capital lines"],
["cutR", "Cutting room: Sillitoe SDMS, spare cutterhead, console and chain, monitoring, room", 200000, 350000, "USD", "est", "SDMS list price USD 125k (vendor, ex tax and shipping); the rest estimate (total-investment.md)"],
["metF", "Metrology, full stack (XRF, laser confocal, wide-area profiler, inline vision, audio station)", 350000, 810000, "USD", "est", "total-investment.md, quality-automation.md; phaseable"],
["metL", "Metrology, launch tier (XRF, inline vision, audio station)", 170000, 460000, "USD", "est", "total-investment.md investor lever: confocal and profiler bought from revenue, saves ~10.1M THB at the midpoint"],
["pvcL", "PVC lab, day-one tier", 55000, 150000, "USD", "est", "quality-automation.md"],
["bTh", "Electric boiler module, 500 kg/h, Thai supplier, installed", 2250000, 3500000, "THB", "est", "Utilities CSV: bank of two 4.5 to 7.0M THB, engineering estimate"],
["bCn", "Electric boiler module, 500 kg/h, Chinese route, installed", 1400000, 2750000, "THB", "est", "china-utilities.md: two ASME-stamped modules 2.8 to 5.5M THB installed"],
["bBio", "Biomass boiler module, 500 kg/h, installed", 3000000, 5000000, "THB", "est", "Utilities CSV: biomass package 6 to 10M THB for ~1 t/h incl. fuel feed, grate and dust control (Getabec class), estimate"],
["bFuel", "Biomass fuel store and handling", 1000000, 2000000, "THB", "est", "Utilities CSV: covered fuel store (42 m2 drawn, ~6 to 7 weeks of fresh chips; ~80 m2 for a 3-month monsoon stock), chip bay, ash bins, a compact loader; estimate (biomass.md: fuel handling and storage come on top of the boiler package)"],
["cBk", "Cooling backbone: plate HX, tanks, pumps, mains for four modules", 1700000, 2700000, "THB", "est", "Utilities CSV: chiller bank of two 4.9 to 7.9M THB less two modules"],
["chM", "Chiller module, 250 kW air-cooled, installed", 1600000, 2600000, "THB", "est", "Utilities CSV: one module ~1.6 to 2.6M THB, estimate"],
["airT", "Compressed air, Thai supplier", 350000, 600000, "THB", "est", "Utilities CSV: screw compressor skid, dryer, receiver"],
["airC", "Compressed air, Chinese brand through its Thai office", 150000, 300000, "THB", "est", "china-utilities.md: PM VSD screw set 0.15 to 0.3M THB installed"],
["clR", "Clean-room adder (galvanics and cutting room)", 1200000, 3400000, "THB", "est", "Utilities CSV, ~2 × 60 m² ISO 7/8; half with only one of the two rooms (estimate)"],
["uRest", "Rest of the utilities baseline: softener, loop treatment, racking, whole-factory HVAC", 8620000, 17300000, "THB", "est", "Utilities CSV baseline 34.47 to 58.22M THB less boilers, fuel store, chillers, air, clean room, PV and battery"],
["pv", "PV EPC per kWp, factory roof", 20000, 26000, "THB", "est", "Utilities CSV: 20 to 26 THB/W installed (DEDE Thailand PV status: factories 10 kW to 1 MW 22 to 25 THB/W, 2023; installers 18 to 25 in 2026) incl. the inverters; times the array size above; canopy steel is in the building and site works (construction RFQ)"],
["bat", "Battery 271 kWh (Gobel), installed", 2000000, 3500000, "THB", "est", "Utilities CSV: Gobel list price USD 39,900 to 58,000 (battery only, matches DEDE's 5,000 to 7,000 THB/kWh for LiFePO4) plus enclosure, fire protection, controls and installation, below the 12,000 to 15,000 THB/kWh of a fully installed factory BESS because the hybrid inverters do the power conversion (estimate)"],
["bld", "Building ~1,500 m² plus civil and site works", 650000, 1100000, "USD", "est", "total-investment.md: 12,000 to 20,000 THB/m² plus ~20 % site works; soil is the main variable"],
["ph2", "Phase 2 building, when the press floor outgrows the press hall", 750000, 1250000, "USD", "est", "Business plan draft 0.1: ~750k to 1,250k on 1,710 m², estimate"],
["crn", "Overhead crane, runway, frame premium, machine door", 97000, 227000, "USD", "est", "crane-workshop-tours.md via total-investment.md"],
["wsh", "Maintenance workshop and machine bay fit-out", 30000, 76000, "USD", "est", "total-investment.md"],
["cor", "Visitor corridor and accessible entrance", 67000, 148000, "USD", "est", "total-investment.md"],
["yard", "Utility yard for four boiler and four chiller modules", 73000, 122000, "USD", "est", "scale.md, total-investment.md"],
["fsw", "Factory software", 60000, 170000, "USD", "est", "total-investment.md, digital-factory.md"],
["erp", "ERPNext implementation", 15000, 50000, "USD", "est", "digital-factory.md"],
["apf", "Artist platform, public side", 40000, 90000, "USD", "est", "artist-platform.md"],
["cas", "Cassette duplication", 40000, 150000, "USD", "est", "Business plan section 4.8 (total-investment.md: with CD-R 55k to 190k)"],
["cdr", "CD-R duplication", 15000, 40000, "USD", "est", "Business plan section 4.9"],
["frt", "Freight, installation, commissioning, training at the launch plant", 150000, 300000, "USD", "est", "total-investment.md; scaled by the Newbilt, Pheenix, Sibert and Sillitoe machinery against the launch plant (estimate)"],
["wc", "Working capital, PVC stock, spares, lease deposit, first-year rent", 135000, 290000, "USD", "est", "total-investment.md; scaled by records a year against 0.84M (estimate)"],
["res", "Ramp-up reserve: staff and overheads before volume covers them", 10000000, 20000000, "THB", "est", "total-investment.md: not in the roll-up, ~10M to 20M THB"],
]);
grp("Investment rules", [
["contPct", "Contingency", 15, "%", "doc", "total-investment.md"],
["craneRent", "Crane rented for the installations, share of the crane block", 40, "%", "est", "Frame stays crane-ready; total-investment.md: saves ~3.7M THB at the midpoint"],
["wcRef", "Records a year the working capital is sized for", 840000, "", "doc", "revenue-model.md launch plant, central volume"],
["wcMin", "Working capital never below", 60, "%", "est", "Estimate"],
["budgetLo", "Plan A budget", 180000000, "THB", "doc", "total-investment.md: Plan A, ~180M THB without contingency, ramp-up reserve included"],
["budgetHi", "Upper budget mark, 180M plus 25 %", 225000000, "THB", "doc", "total-investment.md: the +25 % envelope set for Plan B (user, October 2026); Plan B is ~215M"],
]);
grp("Ten years of cash", [
["ramp1", "Sold output in year 1", 35, "% of the mature year", "est", "Estimate"],
["ramp2", "Sold output in year 2", 70, "% of the mature year", "est", "Estimate"],
["ramp3", "Sold output in year 3", 90, "% of the mature year", "est", "Estimate"],
["ramp4", "Sold output from year 4", 100, "% of the mature year", "est", "Estimate"],
["discRate", "Discount rate for the NPV", 10, "%", "est", "Estimate"],
]);
const A0 = Object.fromEntries(AS.map(r => [r[1], r[3]]));
const KIND = { vendor: "vendor", doc: "doc", est: "estimate", user: "your figure" };
// revenue-model.md ranges: adverse pairs low price with high cost and the high capex end, favourable the reverse
const CASES = {
adverse: { S: { priceStd: 3.4, premSpec: 0.6, titleFee: 580, capexEnd: "high" },
A: { pvcPrice: 2.9, labelCost: 0.09, sleeveCost: 0.17, jacketCost: 0.92, shrinkCost: 0.03, maintCost: 0.23, overheads: 450000, heatPrice: 0.9, deprYears: 10,
titleContrib: 60, stSupplyC: 200, cassN: 20000, cassPrice: 2.5, cdrN: 10000, cdrPrice: 1.5 } },
central: { S: { priceStd: 3.8, premSpec: 1.15, titleFee: 1155, capexEnd: "mid" },
A: { pvcPrice: 2.3, labelCost: 0.06, sleeveCost: 0.13, jacketCost: 0.66, shrinkCost: 0.025, maintCost: 0.17, overheads: 330000, heatPrice: 0.7, deprYears: 12.5,
titleContrib: 65, stSupplyC: 267, cassN: 40000, cassPrice: 3.25, cdrN: 25000, cdrPrice: 2.25 } },
favourable: { S: { priceStd: 4.25, premSpec: 1.7, titleFee: 1730, capexEnd: "low" },
A: { pvcPrice: 1.75, labelCost: 0.03, sleeveCost: 0.09, jacketCost: 0.4, shrinkCost: 0.02, maintCost: 0.12, overheads: 200000, heatPrice: 0.5, deprYears: 15,
titleContrib: 70, stSupplyC: 333, cassN: 60000, cassPrice: 4, cdrN: 40000, cdrPrice: 3 } },
};
function caseNow() {
for (const k in CASES) {
const c = CASES[k];
if (Object.keys(c.S).every(x => S[x] === c.S[x]) && Object.keys(c.A).every(x => A[x] === c.A[x])) return k;
}
return "";
}
const LADDER = [
// Duplex systems first: the hall is laid out for 5 Duplex + 4 Classics (60 %+ specials, October 2026)
["Launch plant", 1, 1], ["Second Duplex", 2, 1], ["Step 1", 3, 2], ["Step 2", 4, 3],
["Full hall", 5, 4], ["Same floor, Classic-led", 2, 11], ["Same floor, Duplex only", 7, 0],
];
// presets: fleet plus plant configuration; prices, case and assumptions stay as they are
const CFG0 = Object.fromEntries(CFGK.map(k => [k, S0[k]]));
const LAUNCH = { mode: "target", target: 850000, grow: "classic", dMin: 1, cMin: 1, dFleet: 1, cFleet: 1, ...CFG0 };
const PRESETS = {
launch: LAUNCH,
// Plan A (~180M THB, six levers) and Plan B (~215M THB, the launch plant all in): total-investment.md "Two plans", priced without contingency
planA: { ...LAUNCH, solar: "ppa", metro: "launch", corridor: "later", cass: "off", cdr: "off", crane: "rent", sourcing: "china", cont: "sep" },
planB: { ...LAUNCH, cont: "sep" },
lean: { ...LAUNCH, galv: "bought", cut: "out" },
full: { mode: "fleet", dFleet: 5, cFleet: 4, ...CFG0 },
};
let S = { ...S0 }, A = { ...A0 };
// ---------------------------------------------------------------- model
function basis(S) {
const hoursYr = S.shifts * S.hrs * S.days, eff = S.eff / 100;
return {
hoursYr,
capD: Math.max(0, S.rateD) * 60 * hoursYr * eff,
capC: Math.max(0, S.rateC) * 60 * hoursYr * eff,
s: Math.min(1, Math.max(0, S.spec / 100)),
f: Math.min(1, Math.max(0.1, S.specF / 100)),
};
}
// Largest yearly output a fleet makes at specials share s: specials only on
// Duplex (at rate factor f), standard on Classics first, then on spare Duplex time.
function capacityOf(D, C, b) {
const x1 = (D * b.capD + C * b.capC) / (b.s / b.f + 1 - b.s);
const x2 = b.s > 0 ? D * b.capD * b.f / b.s : Infinity;
return Math.max(0, Math.min(x1, x2));
}
function sizeFleet(S, b) {
const T = Math.max(0, S.target), sp = T * b.s, st = T - sp;
const dEq = b.capD > 0 ? sp / (b.capD * b.f) : 0;
const dMin = Math.max(0, Math.round(S.dMin)), cMin = Math.max(0, Math.round(S.cMin));
let D, C;
if (b.capD <= 0) return { D: dMin, C: b.capC > 0 ? Math.max(cMin, Math.ceil(st / b.capC - 1e-9)) : cMin };
if (S.grow === "classic" && b.capC > 0) {
D = Math.max(dMin, Math.ceil(dEq - 1e-9));
const spare = (D - dEq) * b.capD;
C = Math.max(cMin, Math.ceil(Math.max(0, st - spare) / b.capC - 1e-9));
} else {
C = cMin;
const stC = Math.min(st, C * b.capC);
D = Math.max(dMin, Math.ceil(dEq + (st - stC) / b.capD - 1e-9));
}
return { D, C };
}
function compute(D, C, X, b) {
const sp = X * b.s, st = X - sp;
const stC = Math.min(st, C * b.capC), stD = st - stC;
const dUse = D > 0 && b.capD > 0 ? (sp / (b.capD * b.f) + stD / b.capD) / D : 0;
const cUse = C > 0 && b.capC > 0 ? stC / (C * b.capC) : 0;
const P = 2 * D + C, E = 2 * D + C; // presses; extruders (EX 50 + EX 35, Classic built in)
const perHour = b.hoursYr > 0 ? X / b.hoursYr : 0;
const att = C > 0 ? Math.ceil(C / Math.max(1, A.cPerAtt)) : 0;
const fin = X > 0 ? Math.ceil(perHour / Math.max(1, A.finRate)) : 0;
const perShift = D * A.opD + att + fin;
const perSide = Math.ceil(A.runLen / Math.max(1, A.stLife));
const partsTitle = 2 * (2 + perSide);
const titles = X / Math.max(1, A.runLen);
const galvIn = S.galv !== "bought", cutIn = S.cut !== "out";
const supply = galvIn ? Math.max(0, S.stSupply || 0) : 0; // titles plated for other plants
const partsYr = galvIn ? titles * partsTitle : 0; // own titles' electroformed parts
const partsAll = galvIn ? (titles + supply) * partsTitle : 0;
const partsDay = partsAll / Math.max(1, S.days);
// stamper production per working day, stations sized from galvanics.md cycle times
const tDay = titles / Math.max(1, S.days), gDay = (titles + supply) / Math.max(1, S.days);
const flow = { titlesDay: tDay, supDay: supply / Math.max(1, S.days), lacq: 2 * tDay, glacq: 2 * gDay,
fathers: 2 * gDay, mothers: 2 * gDay, stampers: 2 * perSide * gDay };
flow.parts = flow.fathers + flow.mothers + flow.stampers;
const avail = S.shifts * S.hrs * 60 * Math.max(1, A.galvEff) / 100;
const need = (dem, cap) => dem <= 0 || cap <= 0 ? { n: 0, u: 0 }
: (n => ({ n, u: dem / (n * cap) }))(Math.max(1, Math.ceil(dem / cap - 1e-9)));
const off = { n: 0, u: 0, off: true };
const galv = galvIn ? {
pasu: need(flow.glacq * A.silverMin, avail),
pppu: need(flow.glacq * (A.preMin + A.handleMin) / 4, avail),
pdpu: need(flow.fathers * (A.fatherMin + A.handleMin) + flow.mothers * (A.motherMin + A.handleMin)
+ flow.stampers * (A.stamperMin + A.handleMin), 2 * avail),
pmpb: need(flow.parts * A.pmpbMin, avail),
vsf: need(flow.stampers * A.vsfMin, avail),
vsp: need(flow.stampers * A.vspMin, avail),
vsc: need(flow.stampers * A.vscMin, avail),
} : { pasu: off, pppu: off, pdpu: off, pmpb: off, vsf: off, vsp: off, vsc: off };
galv.lathe = cutIn ? need(flow.lacq, A.lathe) : off;
const coolPeak = P * A.coolPress + E * A.extCool + P * A.hydCool;
const air = P * A.airPress + E * A.extAir;
const kvaMach = P * A.pressKVA + E * A.extKVA, kwMach = P * A.pressKW + E * A.extKW;
const kvaHyd = P * A.hydKVA, kwHyd = P * A.hydKW;
const steam = [P * A.steamLo, P * A.steamHi];
const mods = (v, m) => v > 0 && m > 0 ? Math.ceil(v / m - 1e-9) : 0;
return {
D, C, X, P, E, sp, st, stC, stD, dUse, cUse, perHour, att, fin, perShift,
staff: perShift * S.shifts, galv: partsDay > 0 ? Math.ceil(partsDay / Math.max(1, A.galvRate)) : 0,
perSide, partsTitle, titles, supply, partsYr, partsAll, partsDay, flow, gst: galv, galvIn, cutIn,
kvaMach, kwMach, kvaHyd, kwHyd, kva: kvaMach + kvaHyd, kw: kwMach + kwHyd,
mwh: (kwMach + kwHyd) * b.hoursYr / 1000,
chillerKW: coolPeak / Math.max(0.5, A.cop), compKW: air * A.compSpec,
steam, coolPeak, nb: mods(steam[1], A.boilerMod), nc: mods(coolPeak, A.chillerMod),
water: [P * A.waterTyp + E * A.extWater + P * A.hydWater, P * A.waterPeak + E * A.extWater + P * A.hydWater],
air, hyd: { flow: P * A.hydFlow, motor: P * A.hydMotor, oil: P * A.hydOil, acc: P * A.hydAcc },
floor: D * A.floorD + C * A.floorC,
compound: X * A.weight * (1 + A.reject / 100) / 1e6,
extKgH: perHour * (A.weight + A.trim) * (1 + A.reject / 100) / 1000,
trimT: X * (1 + A.reject / 100) * A.trim / 1e6,
};
}
function headsOf(r) {
return {
ops: r.staff, qc: r.X > 0 ? Math.ceil(A.qcPerShift * S.shifts) : 0, galv: r.galv,
maint: r.P > 0 ? Math.max(2, Math.ceil(r.P / Math.max(1, A.pressPerTech))) : 0, cut: r.gst.lathe.n,
};
}
// Investment from the blocks of total-investment.md (launch plant, October 2026), in USD.
// c = the plant configuration (S by default, a modified copy for the live deltas).
function capexOf(r, c = S, boiOn = c.boi !== "off") {
const fx = A.thbUsd > 0 ? A.thbUsd : 33, T = v => v / fx;
const rows = [], meta = id => AS.find(x => x[1] === id + "Lo");
// one block: id in AS (Lo/Hi pair), n units, thb = AS figures in THB; o.imp = imported machinery, o.frt = in the freight basis
const blk = (g, id, l, n = 1, thb = false, o = {}) => {
const m = meta(id), lo = thb ? T(A[id + "Lo"]) : A[id + "Lo"], hi = thb ? T(A[id + "Hi"]) : A[id + "Hi"];
rows.push(Object.assign({ g, id, l, lo: n * lo, hi: n * hi, k: m[5], s: m[6] }, o));
};
const galvIn = c.galv !== "bought", cutIn = c.cut !== "out", pl = (n, w) => `${n} × ${w}${n === 1 ? "" : "s"}`;
blk("Preparation", "p0", "Phase 0: BOI consultant, legal and lease work, soil test, re-quotes");
if (r.D > 0) blk("Presses", "dup1", "Newbilt Duplex system 1: two HRP 12, EX 50 + EX 35, trimmer, label oven, shrink wrapper, grinder, label punch", 1, false, { imp: 1, frt: 1 });
if (r.D > 1) blk("Presses", "dupN", `Further Duplex systems: ${r.D - 1} ×`, r.D - 1, false, { imp: 1, frt: 1 });
if (r.C > 0) blk("Presses", "cla", r.C === 1 ? "Newbilt Classic" : `Newbilt Classic: ${r.C} ×`, r.C, false, { imp: 1, frt: 1 });
if (galvIn) {
const oms = c.galvPrice === "oms";
blk("Galvanics and stamper prep", oms ? "gOms" : "gPh", oms ? "Galvanics wet room bought from OMS directly (4 cells)" : "Pheenix galvanics line (PASU, PPPU, PDPU, PMPB), OMS reference plus 20 %",
1, false, { imp: 1, frt: 1, frtMid: (A.gPhLo + A.gPhHi) / 2 });
blk("Galvanics and stamper prep", "gWat", "Galvanics water: ZLD evaporator and DI water", 1, true, { imp: 1 });
blk("Galvanics and stamper prep", "wtb", "Water-treatment building next to galvanics, ~12 × 10 m", 1, true);
blk("Galvanics and stamper prep", "sib", "Sibert VSF, VSP, VSC", 1, false, { imp: 1, frt: 1 });
}
if (cutIn) blk("Cutting room", "cutR", "Cutting room: Sillitoe SDMS, spare cutterhead, console and chain, monitoring, room", 1, false, { imp: 1, frt: 1 });
if (c.metro === "launch") blk("Quality", "metL", "Metrology, launch tier: XRF, inline vision, audio station", 1, false, { imp: 1 });
else blk("Quality", "metF", "Metrology, full stack: XRF, laser confocal, wide-area profiler, inline vision, audio station", 1, false, { imp: 1 });
blk("Quality", "pvcL", "PVC lab, day-one tier");
const bio = c.steamFuel === "biomass", cn = c.sourcing === "china";
if (r.nb > 0 && bio) blk("Utilities", "bFuel", "Biomass fuel store, chip bay, ash bins and a compact loader", 1, true);
if (r.nb > 0) blk("Utilities", bio ? "bBio" : cn ? "bCn" : "bTh",
`Steam: ${pl(r.nb, `${f0(A.boilerMod)} kg/h ${bio ? "biomass" : "electric"} boiler module`)}${bio ? "" : cn ? ", Chinese route" : ", Thai supplier"}`, r.nb, true);
blk("Utilities", "cBk", "Cooling backbone: plate HX, tanks, pumps and mains for four modules", 1, true);
if (r.nc > 0) blk("Utilities", "chM", `Chillers: ${pl(r.nc, `${f0(A.chillerMod)} kW air-cooled module`)}`, r.nc, true);
blk("Utilities", cn ? "airC" : "airT", `Compressed air: screw compressor, dryer, receiver, ${cn ? "Chinese brand through its Thai office" : "Thai supplier"}`, 1, true);
const cr = (galvIn ? 0.5 : 0) + (cutIn ? 0.5 : 0);
if (cr > 0) blk("Utilities", "clR", cr === 1 ? "Clean-room adder: galvanics and cutting room" : `Clean-room adder, half: ${galvIn ? "galvanics" : "cutting room"} only`, cr, true);
blk("Utilities", "uRest", "Rest of the utilities baseline: softener, loop treatment, racking, whole-factory HVAC", 1, true);
if (c.solar === "buy") {
blk("Utilities", "pv", `PV on the factory roof, ${f0(A.pvKWp)} kWp, EPC (office roof and canopies PV-ready)`, A.pvKWp, true);
blk("Utilities", "bat", "Battery 271 kWh (Gobel)", 1, true);
}
blk("Building and site", "bld", "Building ~1,500 m² plus civil and site works");
if (r.floor > A.hall) blk("Building and site", "ph2", `Phase 2 building: ~${f0(r.floor)} m² of press floor outgrow the ${f0(A.hall)} m² press hall`);
if (c.crane === "rent") blk("Building and site", "crn", `Crane rented for the installations (${f0(A.craneRent)} % of the crane block); frame stays crane-ready`, A.craneRent / 100);
else blk("Building and site", "crn", "Overhead crane, runway, frame premium, machine door");
blk("Building and site", "wsh", "Maintenance workshop and machine bay fit-out");
if (c.corridor !== "later") blk("Building and site", "cor", "Visitor corridor and accessible entrance");
blk("Building and site", "yard", "Utility yard for four boiler and four chiller modules");
blk("Software", "fsw", "Factory software");
blk("Software", "erp", "ERPNext implementation");
blk("Software", "apf", "Artist platform, public side");
if (c.cass !== "off") blk("Add-on lines", "cas", "Cassette duplication");
if (c.cdr !== "off") blk("Add-on lines", "cdr", "CD-R duplication");
// freight follows the machinery the line names (Newbilt, Pheenix, Sibert, Sillitoe) at block midpoints;
// the galvanics price basis does not change what is shipped, so the Pheenix midpoint counts either way
const mid = x => (x.lo + x.hi) / 2;
const frtRef = (A.dup1Lo + A.dup1Hi + A.claLo + A.claHi + A.gPhLo + A.gPhHi + A.sibLo + A.sibHi + A.cutRLo + A.cutRHi) / 2;
const frtBase = rows.filter(x => x.frt).reduce((a, x) => a + (x.frtMid != null ? x.frtMid : mid(x)), 0);
const fr = frtRef > 0 ? frtBase / frtRef : 1;
blk("Freight and working capital", "frt", `Freight, installation, commissioning, training: ${pct(fr)} of the launch plant's`, fr);
const wf = Math.max(A.wcMin / 100, A.wcRef > 0 ? r.X / A.wcRef : 1);
blk("Freight and working capital", "wc", `Working capital, PVC stock, spares, lease deposit, first-year rent: ${pct(wf)} of the launch plant's`, wf);
const tri = (lo, hi) => ({ lo, mid: (lo + hi) / 2, hi });
const sumOf = (f, k) => rows.filter(f).reduce((a, x) => a + x[k], 0), all = () => true;
const cont = Math.max(0, A.contPct) / 100;
const sub = tri(sumOf(all, "lo"), sumOf(all, "hi"));
const ctg = tri(sub.lo * cont, sub.hi * cont), ctgIn = c.cont !== "sep";
// Plan A and Plan B are priced without contingency (user, October 2026): then it is shown, not added
const tot = ctgIn ? tri(sub.lo + ctg.lo, sub.hi + ctg.hi) : tri(sub.lo, sub.hi);
const imp = tri(sumOf(x => x.imp, "lo"), sumOf(x => x.imp, "hi"));
const dr = boiOn ? 0 : Math.max(0, A.dutyMach) / 100;
const duty = tri(imp.lo * dr, imp.hi * dr);
const vat = tri(boiOn ? 0 : (imp.lo + duty.lo) * A.vatRate / 100, boiOn ? 0 : (imp.hi + duty.hi) * A.vatRate / 100);
const res = tri(T(A.resLo), T(A.resHi));
const withDuty = tri(tot.lo + duty.lo, tot.hi + duty.hi);
return { rows, sub, ctg, ctgIn, cont, tot, imp, duty, vat, res, all: withDuty, fr, wf, boiOn };
}
// Money a year in USD. Lines: [group, label, USD, basis].
function econ(r, b, boiOn = S.boi !== "off") {
const fx = A.thbUsd > 0 ? A.thbUsd : 33, t2u = v => v / fx;
const X = r.X, pressed = X * (1 + A.reject / 100), st = X - r.sp, L = [];
const add = (g, l, v, s) => L.push([g, l, v, s]);
const cdOn = S.countCD === "on";
const revStd = st * S.priceStd, revSpec = r.sp * (S.priceStd + S.premSpec), revTitle = r.titles * S.titleFee;
const revSup = r.supply * A.stSupplyC;
const revCass = cdOn && S.cass !== "off" ? A.cassN * A.cassPrice : 0, revCdr = cdOn && S.cdr !== "off" ? A.cdrN * A.cdrPrice : 0;
const rev = revStd + revSpec + revTitle + revSup + revCass + revCdr;
add("mat", "PVC compound", r.compound * 1000 * A.pvcPrice, `${f0(r.compound)} t at ${f2(A.pvcPrice)} USD/kg incl. rejects; trim back as regrind`);
const expSh = Math.min(100, Math.max(0, S.exportShare)) / 100;
const pvcImported = r.compound * 1000 * A.pvcPrice * A.pvcImp / 100;
const pvcDuty = pvcImported * A.dutyPVC / 100 * (boiOn ? (1 - expSh) * (1 - A.boiRawRed / 100) : 1);
add("mat", "Import duty on PVC compound", pvcDuty, boiOn
? `BOI: none on the ${pct(expSh)} exported, ${f0(A.boiRawRed)} % off the rest`
: `${f1(A.dutyPVC)} % on ${pct(A.pvcImp / 100)} imported compound, no BOI`);
add("mat", "Labels", pressed * A.labelCost, "a printed pair per pressed record");
add("pack", "Inner sleeves", X * A.sleeveCost, "");
add("pack", "Jackets (printed cartons)", X * A.jacketCost, "local carton partners");
add("pack", "Shrink wrap", X * A.shrinkCost, "");
add("pack", "Shipping cartons and pallets", X * A.cartonCost, "estimate");
add("maint", "Maintenance, spares, consumables", X * A.maintCost, "");
// title services: the steps done in-house earn the contribution, outsourced steps pass through at cost
const shC = Math.max(0, A.shareCut), shP = Math.max(0, A.sharePlate), shS = Math.max(0, A.shareSetup);
const shT = shC + shP + shS > 0 ? shC + shP + shS : 1;
const inSh = (shS + (r.cutIn ? shC : 0) + (r.galvIn ? shP : 0)) / shT;
const lacq = r.cutIn ? r.titles * 2 * A.lacquerCost * A.usdEur : 0, chem = r.partsYr * A.chemPart;
if (r.cutIn) add("title", "Lacquer blanks", lacq, `${f0(r.titles * 2)} lacquers at ${f0(A.lacquerCost)} EUR`);
if (r.galvIn) add("title", "Nickel, silver, plating chemistry", chem, `${f0(r.partsYr)} electroformed parts at ${f0(A.chemPart)} USD`);
const tc = Math.min(100, Math.max(0, A.titleContrib)) / 100;
add("title", "Styli, remakes, other title costs", Math.max(0, revTitle * inSh * (1 - tc) - lacq - chem),
`balance to the ${f0(tc * 100)} % contribution on the in-house steps (${pct(inSh)} of the fee)`);
if (!r.cutIn) add("title", "Mastering and cutting bought in, at cost", revTitle * shC / shT, `${pct(shC / shT)} of the title fee passed through; no contribution`);
if (!r.galvIn) add("title", "Stampers bought in, at cost", revTitle * shP / shT, `${pct(shP / shT)} of the title fee passed through; no contribution`);
// energy
const cap = capacityOf(r.D, r.C, b), use = cap > 0 ? Math.min(1, X / cap) : 0;
const runH = b.hoursYr * S.eff / 100 * use; // press running hours a year
const steamT = r.P * (A.steamLo + A.steamHi) / 2 * runH / 1000;
const heatMWh = steamT * A.steamKWh;
const elecMWh = X > 0 ? r.mwh + heatMWh / Math.max(0.5, A.cop) + (r.compKW + A.restKW) * b.hoursYr / 1000 : 0;
const pvMWh = S.solar === "none" ? 0 : Math.min(elecMWh, A.pvKWp * A.pvYield / 1000 * Math.min(100, Math.max(0, A.pvSelf)) / 100);
const demandKW = X > 0 ? r.kw + r.chillerKW + r.compKW + A.restKW : 0;
add("energy", "Electricity", t2u((elecMWh - pvMWh) * 1000 * A.kwhPrice + demandKW * A.demandPrice * 12),
`${f0(elecMWh - pvMWh)} MWh from the grid after ${f0(pvMWh)} MWh of PV used on site${S.solar === "none" ? " (no PV)" : S.solar === "ppa" ? " (PPA)" : ""}; ~${f0(demandKW)} kW demand`);
if (S.solar === "ppa") add("energy", "Solar PPA power", t2u(pvMWh * 1000 * A.ppaPrice), `${f0(pvMWh)} MWh at ${f2(A.ppaPrice)} THB/kWh (estimate, to quote)`);
if (S.steamFuel === "biomass")
add("energy", "Steam heat (biomass)", t2u(heatMWh * 1000 * A.heatPrice), `${f0(steamT)} t of steam, ${f0(heatMWh)} MWh of heat at ${f2(A.heatPrice)} THB/kWh`);
else {
const eMWh = heatMWh / Math.max(0.5, A.eBoilEff / 100), dKW = X > 0 ? r.nb * A.eBoilKW : 0;
add("energy", "Steam (electric boilers)", t2u(eMWh * 1000 * A.kwhPrice + dKW * A.demandPrice * 12),
`${f0(steamT)} t of steam: ${f0(eMWh)} MWh at ${f2(A.kwhPrice)} THB/kWh plus demand on ${r.nb} × ${f0(A.eBoilKW)} kW`);
}
const h = headsOf(r), ppl = n => n + (n === 1 ? " person" : " people");
let base = 0, ss = 0;
for (const [l, n, sal, why] of [["Press operators and finishing", h.ops, A.salOp], ["QC", h.qc, A.salQC],
["Maintenance", h.maint, A.salMaint], ["Galvanics", h.galv, A.salGalv, r.galvIn ? "" : "stampers bought in"],
["Cutting engineers", h.cut, A.salCut, r.cutIn ? "" : "cutting outsourced"]]) {
add("lab", l, t2u(n * sal * 12), why || `${ppl(n)} at ${f0(sal)} THB a month`);
base += n * sal * 12; ss += n * Math.min(sal, A.ssCap) * A.ssRate / 100 * 12;
}
add("lab", "Employer social security", t2u(ss), `${f1(A.ssRate)} % of salary up to ${f0(A.ssCap)} THB a month`);
add("lab", "Overtime, bonus, PPE", t2u(base * A.otherOn / 100), `${f1(A.otherOn)} % of base salaries`);
h.prod = h.ops + h.qc + h.maint + h.galv + h.cut; h.office = Math.max(0, Math.round(A.officeHeads));
h.all = h.prod + h.office; h.exposed = h.ops + h.galv + h.maint;
add("lab", "Health insurance, all staff", t2u(h.all * A.healthIns), `${ppl(h.all)}, office included, at ${f0(A.healthIns)} THB a year`);
add("lab", "Health check-ups, all staff", t2u(h.all * A.checkup + h.exposed * A.occCheck),
`yearly for everyone, plus risk-factor tests for ${ppl(h.exposed)} on the press floor, in galvanics and maintenance`);
const wwEur = (h.prod * A.wwProd * A.wwProdYr + h.office * A.wwOffice * A.wwOffYr) * (1 + A.wwFreight / 100) * (1 + A.wwDuty / 100);
add("lab", "Workwear, Engelbert Strauss, all staff", wwEur * A.usdEur,
`${f1(h.prod * A.wwProdYr)} production and ${f1(h.office * A.wwOffYr)} office kits a year, shipping and Thai duty included`);
const sum = gs => L.filter(l => gs.includes(l[0])).reduce((a, l) => a + l[2], 0);
const direct = L.reduce((a, l) => a + l[2], 0), contrib = rev - direct, ebitda = contrib - A.overheads;
const cx = capexOf(r, S, boiOn), end = ENUM.capexEnd.includes(S.capexEnd) ? S.capexEnd : "mid";
const ek = { low: "lo", mid: "mid", high: "hi" }[end];
const capex = cx.all[ek], depr = capex / Math.max(1, A.deprYears);
const titleC = revTitle - sum(["title"]);
return { rev, revStd, revSpec, revTitle, revSup, revCass, revCdr, L, sum, direct, contrib, ebitda, capex, depr, op: ebitda - depr,
payback: ebitda > 0 ? capex / ebitda : Infinity, heads: h, imp: cx.imp[ek], dutyM: cx.duty[ek], vatM: cx.vat[ek], pvcDuty, boiOn,
cx, end, ek, titleC, titleRate: revTitle > 0 ? titleC / revTitle : 0, inSh, steamT, heatMWh, pvMWh };
}
// Ten years of cash, USD: year 0 the investment (+ the reserve when it is in the budget), then the ramp.
function cashView(e, r) {
const lab = e.sum(["lab"]), fixed = lab + A.overheads, varCost = e.direct - lab;
const res = S.reserve === "in" ? e.cx.res[e.ek] : 0, c0 = -(e.capex + res);
const Y = [{ y: 0, k: 0, rec: 0, rev: 0, ebitda: 0, cf: c0, cum: c0 }];
for (let y = 1; y <= 10; y++) {
const k = Math.max(0, A["ramp" + Math.min(y, 4)]) / 100;
const ebitda = (e.rev - varCost) * k - fixed;
Y.push({ y, k, rec: r.X * k, rev: e.rev * k, ebitda, cf: ebitda, cum: Y[y - 1].cum + ebitda });
}
let pay = null;
for (let y = 1; y <= 10; y++) if (Y[y].cum >= 0) { pay = y - 1 + (Y[y].cf > 0 ? -Y[y - 1].cum / Y[y].cf : 1); break; }
const npvAt = d => Y.reduce((a, x) => a + x.cf / Math.pow(1 + d, x.y), 0);
const npv = npvAt(A.discRate / 100);
let irr = null, lo = -0.95, hi = 1;
if (npvAt(lo) * npvAt(hi) > 0) hi = 10;
if (npvAt(lo) * npvAt(hi) <= 0) {
for (let i = 0; i < 200; i++) { const m = (lo + hi) / 2; if (npvAt(lo) * npvAt(m) <= 0) hi = m; else lo = m; }
irr = (lo + hi) / 2;
}
return { Y, pay, npv, irr, res, fixed, varCost };
}
function scenario() {
const b = basis(S);
let D, C, X, cap;
if (S.mode === "fleet") {
D = Math.max(0, Math.round(S.dFleet)); C = Math.max(0, Math.round(S.cFleet));
if (D === 0 && b.s > 0) { b.s = 0; b.noSpecials = true; }
cap = capacityOf(D, C, b); X = cap;
} else {
({ D, C } = sizeFleet(S, b));
cap = capacityOf(D, C, b); X = Math.min(Math.max(0, S.target), cap);
}
return { b, cap, r: compute(D, C, X, b) };
}
// ---------------------------------------------------------------- format
const nf = d => new Intl.NumberFormat("en-US", { maximumFractionDigits: d, minimumFractionDigits: 0 });
const f0 = x => nf(0).format(Math.round(x));
const f1 = x => nf(1).format(x);
const big = x => x >= 1e6 ? nf(2).format(x / 1e6) + " M" : f0(x);
const f2 = x => nf(2).format(x);
const fix2 = new Intl.NumberFormat("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
const cv = usd => S.cur === "THB" ? usd * A.thbUsd : S.cur === "EUR" ? usd / Math.max(0.01, A.usdEur) : usd;
function money(usd) {
const v = cv(usd), a = Math.abs(v);
return (a >= 1e6 ? nf(2).format(v / 1e6) + " M" : a >= 1e4 ? f0(v / 1e3) + " k" : f0(v)) + " " + S.cur;
}
const unit = usd => fix2.format(cv(usd)) + " " + S.cur;
const pct = x => Math.round(x * 100) + " %";
const esc = s => String(s).replace(/[&<>"]/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
const $ = id => document.getElementById(id);
// ---------------------------------------------------------------- render
function kpi(v, l, cls) { return `<div class="kpi ${cls || ""}"><b>${v}</b><span>${l}</span></div>`; }
function render() {
const { b, cap, r } = scenario();
$("specV").textContent = S.spec + " %";
$("targetBox").hidden = S.mode !== "target";
$("fleetBox").hidden = S.mode !== "fleet";
for (const g of Object.keys(ENUM)) { const el = $(g); if (el) for (const bt of el.querySelectorAll("button[data-v]")) bt.classList.toggle("on", bt.dataset.v === S[g]); }
const cs = caseNow();
for (const bt of $("case").children) bt.classList.toggle("on", bt.dataset.v === cs);
for (const bt of $("presets").querySelectorAll("button[data-p]"))
bt.classList.toggle("on", Object.entries(PRESETS[bt.dataset.p]).every(([k, v]) => S[k] === v));
$("fleetK").innerHTML =
kpi(r.D, `Duplex systems<br>${2 * r.D} HRP 12 presses, ${r.D} EX 50, ${r.D} EX 35, ${r.D} trimmer${r.D === 1 ? "" : "s"}`, "d") +
kpi(r.C, "Newbilt Classic<br>automatic presses", "c") +
kpi(r.P, "presses in total") +
kpi(r.perShift, `people at the presses and in finishing, per shift (${r.staff} on ${S.shifts} shift${S.shifts > 1 ? "s" : ""})`);
const flags = [];
if (b.noSpecials)
flags.push(["bad", "No Duplex system: this fleet cannot press splatter or specials, so the output shown is black and single colour only."]);
if (S.mode === "target" && S.target > cap + 1)
flags.push(["bad", `The fleet cannot reach ${big(S.target)} records: check the press rates.`]);
if (S.mode === "target" && b.s > 0 && r.D > Math.max(0, Math.round(S.dMin)) && S.grow === "classic")
flags.push(["warn", `Specials alone need ${r.D} Duplex systems at this share.`]);
const fuel = S.steamFuel === "biomass" ? "biomass" : "electric";
if (r.nb > A.modRooms)
flags.push(["warn", `Steam up to ${f0(r.steam[1])} kg/h needs ${r.nb} boiler modules of ${f0(A.boilerMod)} kg/h: beyond the boiler hall built for ${f0(A.modRooms)} modules (a second hall is not costed).`]);
if (r.nc > A.modRooms)
flags.push(["warn", `Cooling up to ${f0(r.coolPeak)} kW needs ${r.nc} chiller modules of ${f0(A.chillerMod)} kW: beyond the chiller compound built for ${f0(A.modRooms)} modules (more pads are not costed).`]);
if (r.nb <= A.modRooms && r.nc <= A.modRooms && r.P > 0)
flags.push(["ok", `Steam ${f0(r.steam[0])} to ${f0(r.steam[1])} kg/h on ${r.nb} × ${f0(A.boilerMod)} kg/h ${fuel} boiler modules; cooling up to ${f0(r.coolPeak)} kW on ${r.nc} × ${f0(A.chillerMod)} kW chiller modules (rooms for ${f0(A.modRooms)} each).`]);
if (r.floor > A.hall)
flags.push(["warn", `About ${f0(r.floor)} m² of press floor: more than the ${f0(A.hall)} m² Payap press hall, so the investment adds a Phase 2 building.`]);
else flags.push(["ok", `About ${f0(r.floor)} m² of press floor: fits the ${f0(A.hall)} m² Payap press hall (${pct(r.floor / A.hall)}).`]);
$("flags").innerHTML = flags.map(([c, t]) => `<div class="flag ${c}">${esc(t)}</div>`).join("");
const use = cap > 0 ? r.X / cap : 0;
$("outK").innerHTML =
kpi(big(r.X), S.mode === "target" ? "records a year (target)" : "records a year at this mix") +
kpi(big(cap), "fleet capacity a year") +
kpi(pct(use), "of capacity used") +
kpi(f0(r.X / Math.max(1, S.days)), "records per working day") +
kpi(f0(r.perHour), "records per operating hour");
const W = r.X > 0 ? r.X : 1;
$("bar").innerHTML =
`<div style="width:${100 * r.sp / W}%;background:var(--dspec)" title="Duplex, specials"></div>` +
`<div style="width:${100 * r.stD / W}%;background:var(--dstd)" title="Duplex, standard"></div>` +
`<div style="width:${100 * r.stC / W}%;background:var(--classic)" title="Classic, standard"></div>`;
$("legend").innerHTML =
`<span><i style="background:var(--dspec)"></i>Duplex, specials ${big(r.sp)} (${pct(r.sp / W)})</span>` +
`<span><i style="background:var(--dstd)"></i>Duplex, black and single colour ${big(r.stD)} (${pct(r.stD / W)})</span>` +
`<span><i style="background:var(--classic)"></i>Classic, black and single colour ${big(r.stC)} (${pct(r.stC / W)})</span>`;
$("mixT").innerHTML =
`<thead><tr><th>Machine</th><th class="n">Units</th><th class="n">Capacity a year</th><th class="n">Makes a year</th><th class="n">Utilisation</th><th class="n">Share of output</th></tr></thead><tbody>` +
`<tr><td>Duplex system (2 presses)</td><td class="n">${r.D}</td><td class="n">${big(r.D * b.capD)}</td><td class="n">${big(r.sp + r.stD)}</td><td class="n">${pct(r.dUse)}</td><td class="n">${pct((r.sp + r.stD) / W)}</td></tr>` +
`<tr><td>Classic</td><td class="n">${r.C}</td><td class="n">${big(r.C * b.capC)}</td><td class="n">${big(r.stC)}</td><td class="n">${pct(r.cUse)}</td><td class="n">${pct(r.stC / W)}</td></tr></tbody>`;
// ---- money
const e = econ(r, b), Xr = Math.max(1, r.X), perRec = usd => r.X > 0 ? unit(usd / Xr) : "–";
const blended = r.X > 0 ? (e.revStd + e.revSpec) / r.X : S.priceStd;
$("priceHint").textContent = `Blended record price at this mix: ${unit(blended)}. Prices are entered in USD; 1 USD = ${f2(A.thbUsd)} THB, 1 EUR = ${nf(3).format(A.usdEur)} USD.`;
const ENDS = { low: "low end", mid: "midpoint", high: "high end" };
$("ecoK").innerHTML =
kpi(money(e.rev), "revenue a year") +
kpi(money(e.ebitda), `EBITDA, ${e.rev > 0 ? pct(e.ebitda / e.rev) : "0 %"} of revenue`) +
kpi(money(e.op), "operating profit after depreciation") +
kpi(perRec(e.direct), "direct cost per record") +
kpi(isFinite(e.payback) ? f1(e.payback) + " years" : "never", `payback on ${money(e.capex)} capex (${ENDS[e.end]})`);
const eOn = econ(r, b, true), eOff = econ(r, b, false);
const pb = x => isFinite(x.payback) ? f1(x.payback) + " years" : "none";
$("boiNote").innerHTML = e.boiOn
? `<div class="flag ok">BOI promotion: no import duty (~${money(eOff.dutyM)}) and no import VAT (~${money(eOff.vatM)}) on ${money(eOn.imp)} of imported machinery, and ${money(eOff.pvcDuty - eOn.pvcDuty)} a year less duty on PVC compound. Without promotion the capex would be ${money(eOff.capex)} and the payback ${pb(eOff)}.</div>`
: `<div class="flag">Not promoted: ${money(eOff.dutyM)} import duty on ${money(eOff.imp)} of imported machinery goes into the capex and its depreciation; ${money(eOff.vatM)} import VAT is paid at the border and only comes back later as input tax (exports are zero-rated, so mostly as a refund claim); ${money(eOff.pvcDuty)} a year duty on PVC compound. With BOI the payback would be ${pb(eOn)}.</div>`;
const SEG = [["PVC and labels", e.sum(["mat"]), "#b07d48"], ["Packaging", e.sum(["pack"]), "#c9a24a"],
["Title services", e.sum(["title"]), "#8e98a6"], ["Energy", e.sum(["energy"]), "#e2462a"],
["Labour", e.sum(["lab"]), "#2f7de1"], ["Maintenance", e.sum(["maint"]), "#7a6fd6"],
["Overheads", A.overheads, "#6b7a90"], ["Depreciation", e.depr, "#4a5f85"]];
const costAll = SEG.reduce((a, x) => a + x[1], 0), scale = Math.max(e.rev, costAll, 1);
$("ecoBar").innerHTML = SEG.map(([l, v, c]) => `<div style="width:${100 * v / scale}%;background:${c}" title="${l}: ${unit(v / Xr)} a record"></div>`).join("") +
(e.op > 0 ? `<div style="width:${100 * e.op / scale}%;background:var(--accent)" title="Operating profit"></div>` : "");
$("ecoLeg").innerHTML = `<span>Per record, revenue ${perRec(e.rev)}:</span>` +
SEG.map(([l, v, c]) => `<span><i style="background:${c}"></i>${l} ${perRec(v)}</span>`).join("") +
(e.op >= 0 ? `<span><i style="background:var(--accent)"></i>Profit ${perRec(e.op)}</span>`
: `<span style="color:var(--bad)">Loss ${perRec(-e.op)} a record</span>`);
const P = [];
const PG = t => P.push(`<tr class="grp"><td colspan="4">${t}</td></tr>`);
const PR = (l, v, s, tot) => P.push(`<tr${tot ? ' class="tot"' : ""}><td>${l}</td><td class="n${v < 0 ? " neg" : ""}">${money(v)}</td><td class="n${v < 0 ? " neg" : ""}">${perRec(v)}</td><td class="src">${s || ""}</td></tr>`);
PG("Revenue");
PR("Standard records", e.revStd, `${big(r.X - r.sp)} at ${unit(S.priceStd)}`);
PR("Splatter and specials", e.revSpec, `${big(r.sp)} at ${unit(S.priceStd + S.premSpec)}`);
PR("Title services", e.revTitle, `${f0(r.titles)} titles at ${money(S.titleFee)}; ${pct(e.inSh)} of it done in-house`);
if (r.galvIn) PR("Stamper supply to other plants", e.revSup, r.supply > 0 ? `${f0(r.supply)} titles at ${money(A.stSupplyC)} contribution (booked at contribution; estimate)` : "none set (Plant configuration)");
const cdNote = S.countCD === "on" ? "" : "not counted (switch under Plant configuration)";
if (S.cass !== "off") PR("Cassettes", e.revCass, cdNote || `${f0(A.cassN)} at ${unit(A.cassPrice)}; illustrative, no running costs on file`);
if (S.cdr !== "off") PR("CD-Rs", e.revCdr, cdNote || `${f0(A.cdrN)} at ${unit(A.cdrPrice)}; illustrative, no running costs on file`);
PR("Revenue", e.rev, "", true);
const GRP = [["Materials, packaging, upkeep", ["mat", "pack", "maint"]], ["Title services: lacquers, plating, bought-in steps", ["title"]], ["Energy", ["energy"]], ["Labour and health care", ["lab"]]];
for (const [t, gs] of GRP) { PG(t); e.L.filter(l => gs.includes(l[0])).forEach(l => PR(l[1], l[2], l[3])); }
PG("Result");
PR("Direct cost", e.direct, "everything above", true);
PR("Contribution", e.contrib, e.rev > 0 ? pct(e.contrib / e.rev) + " of revenue" : "", true);
PR("of which title services", e.titleC, e.revTitle > 0 ? `${pct(e.titleRate)} of the title fee (in-house steps at ${f0(A.titleContrib)} %, outsourced steps at zero)` : "");
PR("Overheads", A.overheads, "admin, sales, insurance, facility, IT, fees, marketing");
PR("EBITDA", e.ebitda, e.rev > 0 ? pct(e.ebitda / e.rev) + " of revenue" : "", true);
PR("Depreciation", e.depr, `${money(e.capex)} capex (${ENDS[e.end]})${e.dutyM > 0 ? ` incl. ${money(e.dutyM)} import duty` : ""} over ${f1(A.deprYears)} years`);
PR("Operating profit", e.op, isFinite(e.payback) ? `payback ${f1(e.payback)} years on EBITDA` : "no payback", true);
$("ecoT").innerHTML = `<thead><tr><th>Line</th><th class="n">A year</th><th class="n">A record</th><th>Basis</th></tr></thead><tbody>${P.join("")}</tbody>`;
const rows = [];
const G = t => rows.push(`<tr class="grp"><td colspan="3">${t}</td></tr>`);
const R = (l, v, s) => rows.push(`<tr><td>${l}</td><td class="n">${v}</td><td class="src">${s || ""}</td></tr>`);
G("People");
R("Duplex operators, per shift", r.D * A.opD, "one operator per Duplex cell");
R("Classic attendants, per shift", r.att, `one per ${f0(A.cPerAtt)} Classics (assumption)`);
R("Finishing: inspection, sleeving, packing, per shift", r.fin, `at ${f0(A.finRate)} records an hour per person (estimate)`);
R("Production staff on all shifts", r.staff, `${r.perShift} per shift × ${S.shifts}; add cover for leave`);
R("QC inspectors, all shifts", e.heads.qc, `${f1(A.qcPerShift)} per shift (estimate; scale.md 1 to 2)`);
R("Maintenance technicians", e.heads.maint, `one per ${f0(A.pressPerTech)} presses, at least 2 (estimate)`);
R("Galvanics operators (day work)", r.galv, r.galvIn ? `${f1(r.partsDay)} parts a day at about ${f0(A.galvRate)} per person (OMS)` : "none: stampers bought in");
R("Cutting engineers (day work)", e.heads.cut, r.cutIn ? "one per lathe (estimate)" : "none: cutting outsourced");
R("Production headcount", e.heads.prod, "add cover for leave");
R("Admin, sales and logistics", e.heads.office, "wages in the overheads (scale.md 3 to 5)");
R("All staff, with health insurance and yearly check-ups", e.heads.all, `risk-factor tests for ${e.heads.exposed} (press floor, galvanics, maintenance)`);
G("Machines");
R("HRP 12 presses (Duplex)", 2 * r.D, "");
R("Classic automatic presses", r.C, "extruder built in; trimming not stated by Newbilt");
R("Extruders", `${r.D} EX 50 + ${r.D} EX 35`, "one main and one colour extruder per Duplex system");
R("Trimmers", r.D, "one per Duplex cell");
R("Heaviest lift", r.C > 0 ? "about 1.85 t" : "1.35 t", r.C > 0 ? "Classic, if it weighs like the Toolex Alpha (unconfirmed)" : "HRP 12 press (Newbilt)");
G("Electrical");
R("Connected load, presses and extruders", f0(r.kvaMach) + " kVA", `${r.P} presses at ${f1(A.pressKVA)} kVA, ${r.E} extruders at ${f1(A.extKVA)} kVA`);
R("Connected load, central hydraulics", f0(r.kvaHyd) + " kVA", `${f1(A.hydKVA)} kVA per press`);
R("Average power while running", f0(r.kw) + " kW", "Newbilt average figures, machines and hydraulics");
R("Energy a year, machines and hydraulics", f0(r.mwh) + " MWh", `${f0(b.hoursYr)} operating hours`);
R("Chiller power at peak cooling", f0(r.chillerKW) + " kW", `COP ${f1(A.cop)} (estimate)`);
R("Air compressors", f1(r.compKW) + " kW", "estimate");
G("Central hydraulics");
R("Pump flow", f0(r.hyd.flow) + " l/min", "up to 200 bar");
R("Installed motor power", f1(r.hyd.motor) + " kW", "");
R("Oil fill, HLP 46", f0(r.hyd.oil) + " l", "");
R("Accumulator volume", f0(r.hyd.acc) + " l", "gas loaded");
G("Steam");
R("Saturated steam, 10 bar, 180 °C", `${f0(r.steam[0])} to ${f0(r.steam[1])} kg/h`, `${r.nb} × ${f0(A.boilerMod)} kg/h ${fuel} boiler modules, hall for ${f0(A.modRooms)}`);
G("Cooling");
R("Cooling capacity, peak", f0(r.coolPeak) + " kW", `about 98 % for the moulds; ${r.nc} × ${f0(A.chillerMod)} kW chiller modules, compound for ${f0(A.modRooms)}`);
R("Cooling water, typical to peak", `${f0(r.water[0])} to ${f0(r.water[1])} l/min`, "inlet 20 to 25 °C, 10 bar");
G("Compressed air");
R("Compressed air", f0(r.air) + " m³/h", "6 to 10 bar, dew point 3 °C, filtered to 5 µm");
G("Floor and room");
R("Press floor", f0(r.floor) + " m²", `Payap press hall ${f0(A.hall)} m²`);
R("Press room climate", "20 to 26 °C", "35 to 60 % r.H., at least 2.5 m clear height (Newbilt)");
G("Material");
R("PVC compound a year", f0(r.compound) + " t", `${f0(A.weight)} g a record plus ${f0(A.reject)} % rejects`);
R("Through the extruders", f0(r.extKgH) + " kg/h", "record plus trim, per operating hour");
R("Trim back to the granulator", f0(r.trimT) + " t a year", `${f0(A.trim)} g a record, reused as regrind`);
G("Stampers and galvanics");
R("Titles a year", f0(r.titles), `${f0(A.runLen)} records a title`);
if (r.galvIn) {
R("Electroformed parts a year", f0(r.partsAll), `${r.partsTitle} a title: father, mother and ${r.perSide} stamper${r.perSide > 1 ? "s" : ""} per side${r.supply > 0 ? `; incl. ${f0(r.supply)} titles for other plants` : ""}`);
R("Per working day", `${f1(r.flow.glacq)} / ${f1(r.flow.fathers)} / ${f1(r.flow.mothers)} / ${f1(r.flow.stampers)}`, "lacquers / fathers / mothers / stampers, before remakes");
} else R("Stampers bought in a year", f0(r.titles * 2 * r.perSide), `${r.perSide} per side a title; electroforming outsourced`);
const GS = r.gst, su = k => GS[k].off ? (k === "lathe" ? "outsourced" : "bought in") : `${GS[k].n} · ${pct(GS[k].u)}`;
R("Lathes", su("lathe"), `units · busy; ${f0(A.lathe)} lacquer sides a day each`);
R("Silvering, Pheenix PASU", su("pasu"), "");
R("Pre-plating, Pheenix PPPU", su("pppu"), "four masters at once");
R("Plating, Pheenix PDPU (2 cells)", su("pdpu"), "a second unit adds redundancy, not capacity, at low load");
R("Passivation, Pheenix PMPB", su("pmpb"), "");
R("Sibert VSF / VSP / VSC", GS.vsf.off ? "bought in" : `${GS.vsf.n} / ${GS.vsp.n} / ${GS.vsc.n}`, GS.vsf.off ? "stamper prep with the bought stampers" : `busiest ${pct(Math.max(GS.vsf.u, GS.vsp.u, GS.vsc.u))}`);
$("reqT").innerHTML = `<tbody>${rows.join("")}</tbody>`;
$("ladT").innerHTML =
`<thead><tr><th>Fleet</th><th class="n">Duplex</th><th class="n">Classic</th><th class="n">Presses</th><th class="n">Records a year</th><th class="n">Specials a year</th><th class="n">Staff per shift</th><th class="n">Steam, kg/h</th><th class="n">Cooling, kW</th><th class="n">Connected, kVA</th><th class="n">Press floor, m²</th><th class="n">Capex, mid</th><th class="n">Revenue</th><th class="n">EBITDA</th></tr></thead><tbody>` +
LADDER.map(([n, d, c], i) => {
const x = capacityOf(d, c, b), q = compute(d, c, x, b), qe = econ(q, b);
const cur = r.D === d && r.C === c ? " cur" : "";
return `<tr class="lad${cur}" data-i="${i}"><td>${n}</td><td class="n">${d}</td><td class="n">${c}</td><td class="n">${q.P}</td><td class="n">${big(x)}</td><td class="n">${big(q.sp)}</td><td class="n">${q.perShift}</td><td class="n">${f0(q.steam[1])}</td><td class="n">${f0(q.coolPeak)}</td><td class="n">${f0(q.kva)}</td><td class="n">${f0(q.floor)}</td><td class="n">${money(qe.cx.all.mid)}</td><td class="n">${money(qe.rev)}</td><td class="n${qe.ebitda < 0 ? " neg" : ""}">${money(qe.ebitda)}</td></tr>`;
}).join("") + "</tbody>";
drawPark(r, b);
renderCfg(r);
renderInvest(e, r);
renderBudget(e);
renderCash(e, r);
isoUpdate(r);
for (const tr of document.querySelectorAll("#asT tr[data-id]"))
tr.classList.toggle("chg", tr.dataset.id.split(",").some(id => A[id] !== A0[id]));
writeHash();
}
// ---------------------------------------------------------------- plant configuration, investment, budget, cash
const CFG = [
{ k: "galv", t: "Galvanics", sub: "Pheenix line, ZLD and DI water, water-treatment building, Sibert stamper prep",
o: [["in", "In-house"], ["bought", "Bought stampers"]],
h: { in: "Fathers, mothers and stampers made here; the plating share of the title fee earns its contribution.",
bought: "Stampers bought in and passed through at cost: no galvanics line, operators, ZLD or water-treatment building." } },
{ k: "galvPrice", t: "Galvanics price basis", sub: "only with galvanics in-house",
o: [["pheenix", "Pheenix (OMS +20 %)"], ["oms", "OMS directly"]],
h: { pheenix: () => `${kU("gPh")} USD: OMS's reference for a 4-cell wet room plus 20 % (user, October 2026); no Pheenix price on file.`,
oms: () => `${kU("gOms")} USD: OMS's 4-cell wet room, EUR 234k to 321k with the know-how transfer (letter, 9 October 2026).` },
dis: () => S.galv === "bought" ? "Not applicable: the stampers are bought in." : "",
extra: `<label class="row" id="row_stSupply"><span>Stamper supply to other plants, titles a year<br><small>contribution 200 to 333 USD a title (estimate); galvanics in-house only</small></span><input type="number" id="stSupply" min="0" step="50"></label>` },
{ k: "cut", t: "Mastering and cutting room", sub: "Sillitoe SDMS lathe, console and chain, room",
o: [["in", "In-house"], ["out", "Outsourced"]],
h: { in: "Lacquers cut here: cutting engineer and lacquer blanks in the costs, the mastering share earns its contribution.",
out: "Lacquers cut by an outside studio and passed through at cost: no cutting engineer, no lacquer blanks." } },
{ k: "metro", t: "Metrology", sub: "stamper and record measurement",
o: [["full", "Full stack"], ["launch", "Launch tier"]],
h: { full: () => `XRF, laser confocal, wide-area profiler, inline vision, audio station: ${kU("metF")} USD.`,
launch: () => `XRF, inline vision, audio station: ${kU("metL")} USD; confocal and profiler bought from revenue later.` } },
{ k: "cass", t: "Cassette duplication", sub: "small add-on line",
o: [["on", "On"], ["off", "Off"]], h: { on: () => `${kU("cas")} USD (estimate).`, off: "Not at launch." } },
{ k: "cdr", t: "CD-R duplication", sub: "small add-on line; CD replication is a separate option",
o: [["on", "On"], ["off", "Off"]], h: { on: () => `${kU("cdr")} USD (estimate).`, off: "Not at launch." } },
{ k: "countCD", t: "Count cassette and CD-R revenue", sub: "off in the totals, as in the revenue model",
o: [["off", "Not counted"], ["on", "Counted"]],
h: { off: "Illustrative volumes and prices with no market data; left out of the money.",
on: "Counted at the illustrative volumes and prices, with no running costs on file: it flatters the result." },
dis: () => S.cass === "off" && S.cdr === "off" ? "Not applicable: neither line is in the plant." : "" },
{ k: "solar", t: "Solar PV and 271 kWh battery", sub: "the factory roof (~520 kWp; office and canopies PV-ready) plus the Gobel battery",
o: [["buy", "Buy"], ["ppa", "Solar PPA"], ["none", "None"]],
h: { buy: () => `${nf(1).format(A.pvLo * A.pvKWp / 1e6)}M to ${nf(1).format(A.pvHi * A.pvKWp / 1e6)}M THB for ${f0(A.pvKWp)} kWp plus ${mT("bat")} battery; the yield offsets the bill.`,
ppa: "No capex: the EPC owns the array, the plant pays the PPA price on the yield.",
none: "No PV: the whole bill comes from the grid." },
extra: `<label class="row" id="row_ppa"><span>PPA power price, THB/kWh<br><small>estimate, to quote</small></span><input type="number" id="ppaIn" data-a="ppaPrice" min="0" step="0.05"></label>` },
{ k: "steamFuel", t: "Steam fuel", sub: "the boiler modules follow the fleet's peak steam",
o: [["electric", "Electric"], ["biomass", "Biomass"]],
h: { electric: "Electric modules on PEA power: energy charge plus demand charge per installed module.",
biomass: "Biomass modules (utilities CSV package) plus the fuel store; heat at the biomass price. Both plans run on biomass (October 2026)." } },
{ k: "sourcing", t: "Utilities sourcing", sub: "the compressor, and the boiler modules when steam is electric",
o: [["thai", "Thai suppliers"], ["china", "Chinese route"]],
h: { thai: "Thai suppliers, installed (utilities CSV).",
china: "ASME-stamped bodies installed by a Thai contractor, compressor through a Chinese brand's Thai office (china-utilities.md)." } },
{ k: "crane", t: "Overhead crane", sub: "5 t crane over the production compartment",
o: [["buy", "Buy"], ["rent", "Rent for installations"]],
h: { buy: () => `Crane, runway, frame premium, machine door: ${kU("crn")} USD.`,
rent: () => `A mobile crane for the installations; the frame stays crane-ready (${f0(A.craneRent)} % of the crane block, estimate).` } },
{ k: "corridor", t: "Visitor corridor", sub: "glazed corridor along the process, accessible entrance",
o: [["now", "At launch"], ["later", "Later"]],
h: { now: () => `${kU("cor")} USD at launch.`, later: "Built later; no public tours at first." } },
{ k: "reserve", t: "Ramp-up reserve", sub: "10M to 20M THB: staff and overheads before volume covers them",
o: [["in", "In the budget"], ["sep", "Shown separately"]],
h: { in: "Added to the budget bars and to year 0 of the cash view; never to the investment total.",
sep: "Left out of the budget bars and year 0; shown as a line under the investment." } },
{ k: "cont", t: "Contingency", sub: "15 % of the subtotal (Assumptions)",
o: [["in", "In the total"], ["sep", "Shown separately"]],
h: { in: "Added to the investment total, as in the launch plant roll-up of total-investment.md.",
sep: "Left out of the total, as Plan A and Plan B are priced (user, October 2026); shown as a line under the subtotal." } },
];
function buildCfg() {
$("cfg").innerHTML = CFG.map(c => `<div class="cfg" id="cfg_${c.k}"><div class="ct">${c.t}<small>${c.sub}</small></div>` +
`<div class="seg" id="${c.k}">${c.o.map(([v, l]) => `<button type="button" data-v="${v}"><span>${l}</span><small class="dl"></small></button>`).join("")}</div>` +
`<p class="hint" id="h_${c.k}"></p>${c.extra || ""}</div>`).join("");
}
const kU = id => `${f0(A[id + "Lo"] / 1e3)}k to ${f0(A[id + "Hi"] / 1e3)}k`;
const mT = id => `${nf(2).format(A[id + "Lo"] / 1e6)}M to ${nf(2).format(A[id + "Hi"] / 1e6)}M`;
const sgn = usd => Math.abs(usd) < 1 ? "no change" : (usd > 0 ? "+" : "\u2212") + money(Math.abs(usd));
function renderCfg(r) {
const base = capexOf(r, S);
for (const c of CFG) {
const why = c.dis ? c.dis() : "", box = $("cfg_" + c.k);
box.classList.toggle("off", !!why);
for (const bt of $(c.k).querySelectorAll("button[data-v]")) {
const v = bt.dataset.v, dl = bt.querySelector(".dl");
bt.disabled = !!why; bt.title = why;
let t = "";
if (why) t = "n/a";
else if (v === S[c.k]) t = "current";
else if (c.k === "reserve") t = sgn((v === "in" ? 1 : -1) * base.res.mid) + " budget";
else if (c.k === "countCD") t = v === "on" ? "revenue only" : "";
else t = sgn(capexOf(r, { ...S, [c.k]: v }).all.mid - base.all.mid);
dl.textContent = t;
}
const hv = c.h[S[c.k]];
$("h_" + c.k).textContent = why || (typeof hv === "function" ? hv() : hv) || "";
}
const supOff = S.galv === "bought", ppaOff = S.solar !== "ppa";
$("stSupply").disabled = supOff; $("row_stSupply").classList.toggle("off", supOff);
$("stSupply").title = supOff ? "Not applicable: the stampers are bought in." : "";
$("ppaIn").disabled = ppaOff; $("row_ppa").classList.toggle("off", ppaOff);
$("ppaIn").title = ppaOff ? "Only with a solar PPA." : "";
}
const thbM = usd => nf(1).format(usd * A.thbUsd / 1e6) + "M THB";
function renderInvest(e, r) {
const cx = e.cx, H = [];
let last = "";
for (const x of cx.rows) {
if (x.g !== last) { H.push(`<tr class="grp"><td colspan="5">${esc(x.g)}</td></tr>`); last = x.g; }
H.push(`<tr><td>${esc(x.l)}</td><td class="n">${money(x.lo)}</td><td class="n">${money((x.lo + x.hi) / 2)}</td><td class="n">${money(x.hi)}</td>` +
`<td class="src"><span class="badge ${x.k}">${KIND[x.k]}</span> ${esc(x.s)}</td></tr>`);
}
const TR = (l, o, s, tot) => H.push(`<tr${tot ? ' class="tot"' : ""}><td>${l}</td><td class="n">${money(o.lo)}</td><td class="n">${money(o.mid)}</td><td class="n">${money(o.hi)}</td><td class="src">${s || ""}</td></tr>`);
H.push(`<tr class="grp"><td colspan="5">Totals</td></tr>`);
TR("Subtotal", cx.sub, "", true);
TR(cx.ctgIn ? `Contingency ${f1(A.contPct)} %` : `Contingency ${f1(A.contPct)} % (not in the total)`, cx.ctg, cx.ctgIn ? "total-investment.md" : "shown separately, as in Plan A and Plan B");
TR("Total investment", cx.tot, "for reference, total-investment.md: the launch plant ~168M to 296M THB with contingency (midpoint ~232M); Plan A ~167M and Plan B ~202M at the midpoint without it, plus the ramp-up reserve", true);
if (!cx.boiOn) {
TR(`Import duty without BOI, ${f1(A.dutyMach)} %`, cx.duty, `on ${money(cx.imp.lo)} to ${money(cx.imp.hi)} of imported machinery; import VAT ${money(cx.vat.mid)} at the midpoint, reclaimable`);
TR("Total with import duty", cx.all, "", true);
}
TR("Ramp-up reserve (not in the total)", cx.res, S.reserve === "in" ? "in the budget bars and year 0 of the cash view" : "shown separately");
if (S.reserve === "in") TR("Total with the reserve", { lo: cx.all.lo + cx.res.lo, mid: cx.all.mid + cx.res.mid, hi: cx.all.hi + cx.res.hi }, "what the budget card compares", true);
$("ivT").innerHTML = `<thead><tr><th>Block</th><th class="n">Low</th><th class="n">Mid</th><th class="n">High</th><th>Basis</th></tr></thead><tbody>${H.join("")}</tbody>`;
$("ivK").innerHTML = kpi(money(cx.all.lo), cx.ctgIn ? "low end, contingency included" : "low end, without contingency") + kpi(money(cx.all.mid), "midpoint") +
kpi(money(cx.all.hi), "high end") + kpi(thbM(cx.all.mid), `midpoint in THB (${thbM(cx.all.lo)} to ${thbM(cx.all.hi)})`);
const fl = [];
if (r.nb > A.modRooms) fl.push(["warn", `${r.nb} boiler modules: beyond the boiler hall built for ${f0(A.modRooms)}; a second hall is not in the investment.`]);
if (r.nc > A.modRooms) fl.push(["warn", `${r.nc} chiller modules: beyond the chiller compound built for ${f0(A.modRooms)}; more pads are not in the investment.`]);
if (r.floor > A.hall) fl.push(["warn", `Press floor ~${f0(r.floor)} m² outgrows the ${f0(A.hall)} m² press hall: a Phase 2 building is included (business plan draft 0.1, estimate).`]);
if (cx.fr > 1.05 || cx.wf > 1.05) fl.push(["", `Scaled from the launch plant (estimates): freight and installation ${pct(cx.fr)}, working capital ${pct(cx.wf)}.`]);
$("ivFlags").innerHTML = fl.map(([c, t]) => `<div class="flag ${c}">${esc(t)}</div>`).join("");
}
function renderBudget(e) {
const cx = e.cx, fx = A.thbUsd > 0 ? A.thbUsd : 33, inB = S.reserve === "in";
const B1 = A.budgetLo, B2 = A.budgetHi, M = v => nf(1).format(v / 1e6) + "M";
const ends = [["lo", "Low end"], ["mid", "Midpoint"], ["hi", "High end"]];
const V = ends.map(([k, l]) => ({ k, l, cap: cx.all[k] * fx, res: inB ? cx.res[k] * fx : 0 }));
const max = Math.max(B2, ...V.map(v => v.cap + v.res)) * 1.05;
const gap = (v, B) => v <= B ? `<span class="fit">${M(B - v)} to spare</span>` : `<span class="over">${M(v - B)} over</span>`;
$("bud").innerHTML = V.map((v, i) => {
const tot = v.cap + v.res, w = x => (100 * x / max).toFixed(2) + "%";
return `<div class="bl"><b>${v.l}</b>${M(tot)} THB</div><div class="btrack" title="${v.l}: ${M(v.cap)} THB investment${inB ? ` + ${M(v.res)} reserve` : ""}">` +
`<div class="bfill" style="width:${w(v.cap)}"></div>` +
(v.res > 0 ? `<div class="bfill res" style="left:${w(v.cap)};width:${w(v.res)}"></div>` : "") +
`<div class="bmark lo" style="left:${w(B1)}">${i === 0 ? `<i>${M(B1)}</i>` : ""}</div>` +
`<div class="bmark" style="left:${w(B2)}">${i === 0 ? `<i>${M(B2)}</i>` : ""}</div></div>` +
`<div class="bgap">against ${M(B1)}: ${gap(tot, B1)} · against ${M(B2)}: ${gap(tot, B2)}</div>`;
}).join("");
const mid = V[1], mt = mid.cap + mid.res, rLo = cx.res.lo * fx, rHi = cx.res.hi * fx;
const tgt = mt <= B1 ? B1 : B2; // the tightest budget the midpoint fits (Plan A: 180M, Plan B: the 225M mark)
$("budK").innerHTML = kpi(M(mid.cap) + " THB", `investment at the midpoint, ${cx.ctgIn ? "contingency included" : "without contingency"}`) +
kpi(`${M(mid.cap + rLo)} to ${M(mid.cap + rHi)}`, `THB with the ramp-up reserve (${M(rLo)} to ${M(rHi)})`) +
kpi(mt <= tgt ? M(tgt - mt) : "+" + M(mt - B2), `${mt <= tgt ? "to spare under" : "over"} ${M(tgt)} THB at the midpoint${inB ? ", reserve included" : ""}`);
const verdict = mt <= tgt ? `Mid fits ${M(tgt)} with ${M(tgt - mt)} to spare` : `Mid is ${M(mt - B2)} over ${M(B2)}`;
const detail = inB
? `the investment ${M(mid.cap)} plus the ramp-up reserve, ${M(mid.cap + rLo)} to ${M(mid.cap + rHi)} THB with the reserve at ${M(rLo)} to ${M(rHi)}`
: `the investment ${M(mid.cap)} THB without the reserve; with it, ${M(mid.cap + rLo)} to ${M(mid.cap + rHi)}`;
const vs180 = mt <= B1 ? `against ${M(B2)} it has ${M(B2 - mt)} to spare` : `against ${M(B1)} it is ${M(mt - B1)} over`;
$("budV").innerHTML = `<div class="flag ${mt <= B2 ? "ok" : "bad"}"><b>${verdict}</b>: ${detail}; ${vs180}. ` +
`The low end is ${M(V[0].cap + V[0].res)}, the high end ${M(V[2].cap + V[2].res)} THB.</div>`;
}
function renderCash(e, r) {
const cv = cashView(e, r), Y = cv.Y, d = A.discRate;
const pay = cv.pay == null ? "beyond year 10" : "year " + f1(cv.pay);
$("cashK").innerHTML = kpi(pay, "payback, cumulative cash back to zero") +
kpi(money(cv.npv), `NPV at ${f1(d)} %, ten years, no terminal value`) +
kpi(cv.irr == null ? "none" : nf(1).format(cv.irr * 100) + " %", "IRR over ten years") +
kpi(money(Y[10].cum), "cumulative cash after year 10");
// chart: yearly cash flow as bars, cumulative cash as a line
const W = 640, Ht = 230, l = 70, rt = 12, t = 14, bt = 26;
const vals = Y.flatMap(y => [y.cf, y.cum]).concat(0), lo = Math.min(...vals), hi = Math.max(...vals), span = hi - lo || 1;
const yy = v => t + (hi - v) / span * (Ht - t - bt), xs = (W - l - rt) / 11, xc = i => l + xs * (i + 0.5);
const o = [`<line x1="${l}" y1="${t}" x2="${l}" y2="${Ht - bt}" class="cv-axis"/>`, `<line x1="${l}" y1="${yy(0)}" x2="${W - rt}" y2="${yy(0)}" class="cv-zero"/>`];
for (const v of [hi, 0, lo]) o.push(`<text x="${l - 6}" y="${yy(v) + 4}" class="cv-t" text-anchor="end">${money(v)}</text>`);
Y.forEach((y, i) => {
const y0 = yy(0), y1 = yy(y.cf);
o.push(`<rect x="${(xc(i) - xs * 0.3).toFixed(1)}" y="${Math.min(y0, y1).toFixed(1)}" width="${(xs * 0.6).toFixed(1)}" height="${Math.max(0.5, Math.abs(y1 - y0)).toFixed(1)}" class="${y.cf < 0 ? "cv-neg" : "cv-pos"}"/>`);
o.push(`<text x="${xc(i).toFixed(1)}" y="${Ht - 8}" class="cv-t" text-anchor="middle">${i === 0 ? "Y0" : i}</text>`);
});
o.push(`<path d="${Y.map((y, i) => (i ? "L" : "M") + xc(i).toFixed(1) + "," + yy(y.cum).toFixed(1)).join(" ")}" class="cv-line"/>`);
Y.forEach((y, i) => o.push(`<circle cx="${xc(i).toFixed(1)}" cy="${yy(y.cum).toFixed(1)}" r="3" class="cv-dot"/>`));
$("cashSvg").innerHTML = o.join("");
$("cashLeg").innerHTML = `<span><i style="background:var(--accent)"></i>cash in a year</span><span><i style="background:var(--bad)"></i>cash out a year</span>` +
`<span><i style="background:var(--dstd)"></i>cumulative cash</span><span>Year 0: ${money(e.capex)} investment (${{ low: "low end", mid: "midpoint", high: "high end" }[e.end]})${cv.res > 0 ? ` + ${money(cv.res)} reserve` : ""}</span>`;
const rows = Y.map(y => `<tr><td>${y.y === 0 ? "Year 0" : "Year " + y.y}</td><td class="n">${y.y ? pct(y.k) : "–"}</td><td class="n">${y.y ? big(y.rec) : "–"}</td>` +
`<td class="n">${y.y ? money(y.rev) : "–"}</td><td class="n${y.ebitda < 0 ? " neg" : ""}">${y.y ? money(y.ebitda) : "–"}</td>` +
`<td class="n${y.cf < 0 ? " neg" : ""}">${money(y.cf)}</td><td class="n${y.cum < 0 ? " neg" : ""}">${money(y.cum)}</td></tr>`);
$("cashT").innerHTML = `<thead><tr><th>Year</th><th class="n">Sold output</th><th class="n">Records</th><th class="n">Revenue</th><th class="n">EBITDA</th><th class="n">Cash flow</th><th class="n">Cumulative</th></tr></thead><tbody>${rows.join("")}</tbody>`;
}
// ---------------------------------------------------------------- machine park schematic
// ---------------------------------------------------------------- plant view (CNXIso)
let ISO = null, ISOK = "";
function isoCfg(r) {
return { duplex: r.D, classic: r.C, galvanics: S.galv, cutting: S.cut, metrology: S.metro,
cassette: S.cass, cdr: S.cdr, solar: S.solar, pvKwp: A.pvKWp, batteryKwh: 271, fuel: S.steamFuel,
boilerModules: r.nb, chillerModules: r.nc, crane: S.crane, corridor: S.corridor, phase2: r.floor > A.hall };
}
function isoUpdate(r) {
const el = document.getElementById("isoPlant");
if (!el || typeof CNXIso === "undefined") return;
const cfg = isoCfg(r), k = JSON.stringify(cfg);
if (!ISO) { ISO = CNXIso.mount(el, cfg, { note: true }); ISOK = k; return; }
if (k !== ISOK) { ISO.update(cfg); ISOK = k; }
}
function drawPark(r, b) {
const o = [];
const T = (x, y, c, t, a) => o.push(`<text x="${x}" y="${y}" class="${c}"${a ? ` text-anchor="${a}"` : ""}>${t}</text>`);
const box = (x, y, w, h, c, rx) => o.push(`<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="${rx == null ? 6 : rx}" class="${c}"/>`);
const arrow = (x1, y1, x2, y2) => o.push(`<path d="M${x1},${y1} L${x2},${y2}" class="pk-arrow" marker-end="url(#pkA)"/>`);
const bar = (x, y, w, u) => { box(x, y, w, 5, "pk-util", 2); box(x, y, Math.max(0, w * Math.min(u, 1)), 5, "pk-utilv" + (u > 0.9 ? " hi" : ""), 2); };
o.push(`<defs><marker id="pkA" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" class="pk-head"/></marker></defs>`);
const glyph = (k, x, y) => {
if (k === "lathe") o.push(`<circle cx="${x + 10}" cy="${y + 11}" r="9" class="pk-glyph"/><circle cx="${x + 10}" cy="${y + 11}" r="2" class="pk-head"/>`);
else if (k === "pdpu") o.push(`<rect x="${x}" y="${y}" width="9" height="22" rx="2" class="pk-glyph"/><rect x="${x + 11}" y="${y}" width="9" height="22" rx="2" class="pk-glyph"/>`);
else if (k === "pppu") o.push(`<rect x="${x}" y="${y}" width="20" height="22" rx="2" class="pk-glyph"/>` + [0, 1, 2, 3].map(i => `<circle cx="${x + 5 + (i % 2) * 10}" cy="${y + 6 + Math.floor(i / 2) * 10}" r="2.4" class="pk-head"/>`).join(""));
else if (k === "pmpb") o.push(`<rect x="${x}" y="${y + 7}" width="20" height="15" rx="2" class="pk-glyph"/>`);
else o.push(`<rect x="${x}" y="${y}" width="20" height="22" rx="2" class="pk-glyph"/><circle cx="${x + 10}" cy="${y + 11}" r="6" class="pk-head" opacity=".55"/>`);
};
const f = r.flow, g = r.gst, days = Math.max(1, S.days);
// 1. stamper production
T(17, 18, "pk-lane", r.galvIn
? `STAMPER PRODUCTION · ${f1(f.titlesDay)} TITLES A WORKING DAY (${f0(r.titles)} A YEAR)${r.supply > 0 ? ` + ${f1(f.supDay)} FOR OTHER PLANTS` : ""} · ${f1(f.parts)} ELECTROFORMED PARTS A DAY`
: `STAMPER SUPPLY · ${f1(f.titlesDay)} TITLES A WORKING DAY (${f0(r.titles)} A YEAR) · STAMPERS BOUGHT IN${r.cutIn ? ", LACQUERS CUT HERE" : ""}`);
const ST = [
["Mastering", "lathe", g.lathe, `${f1(f.lacq)} lacquer sides`, "lathe"],
["Silvering", "Pheenix PASU", g.pasu, `${f1(f.glacq)} lacquers`, "pasu"],
["Pre-plating", "Pheenix PPPU", g.pppu, `${f1(f.glacq)} masters`, "pppu"],
["Plating", "Pheenix PDPU, 2 cells", g.pdpu, `${f1(f.parts)} parts`, "pdpu"],
["Passivation", "Pheenix PMPB", g.pmpb, `${f1(f.parts)} parts`, "pmpb"],
["Back-finish", "Sibert VSF", g.vsf, `${f1(f.stampers)} stampers`, "vsf"],
["Centre punch", "Sibert VSP", g.vsp, `${f1(f.stampers)} stampers`, "vsp"],
["Coin", "Sibert VSC", g.vsc, `${f1(f.stampers)} stampers`, "vsc"],
];
const bw = 130, gp = 18, y0 = 28, bh = 112;
ST.forEach(([t, sub, u, fl, k], i) => {
const x = 17 + i * (bw + gp);
if (u.off) {
o.push(`<g class="pk-off">`);
box(x, y0, bw, bh, "pk-box");
T(x + 8, y0 + 17, "pk-t", t); T(x + 8, y0 + 33, "pk-s", sub);
glyph(k, x + 8, y0 + 38);
o.push(`</g>`);
T(x + 8, y0 + 80, "pk-tag", k === "lathe" ? "OUTSOURCED" : "BOUGHT IN");
T(x + 8, y0 + 96, "pk-s", k === "lathe" ? "cut by a studio" : "stampers bought");
} else {
box(x, y0, bw, bh, "pk-box");
T(x + 8, y0 + 17, "pk-t", t); T(x + 8, y0 + 33, "pk-s", sub);
for (let j = 0; j < Math.min(u.n, 3); j++) glyph(k, x + 8 + j * 24, y0 + 38);
T(x + bw - 8, y0 + 58, "pk-n", u.n + "×", "end");
T(x + 8, y0 + 80, "pk-s", fl + " a day");
bar(x + 8, y0 + 88, bw - 16, u.u);
T(x + 8, y0 + 106, "pk-s", pct(u.u) + " busy");
}
if (i < ST.length - 1) arrow(x + bw + 2, y0 + bh / 2, x + bw + gp - 2, y0 + bh / 2);
});
// 2. press hall, with compound in (left), stampers in and records out (right)
const items = [];
for (let i = 0; i < r.D; i++) items.push(["D", i + 1]);
for (let i = 0; i < r.C; i++) items.push(["C", i + 1]);
const hx = 165, hw = 870, iw = { D: 150, C: 84 }, ih = 104, innerW = hw - 30;
const lay = sc => { let x = 0, y = 0; const pos = [];
for (const it of items) { const w = iw[it[0]] * sc; if (x > 0 && x + w > innerW + 0.5) { x = 0; y += (ih + 12) * sc; } pos.push([x, y]); x += w + 12 * sc; }
return { pos, h: items.length ? y + ih * sc : 0 }; };
let sc = 1, L = lay(1);
while (L.h > 480 && sc > 0.3) { sc *= 0.85; L = lay(sc); }
const y1 = y0 + bh + 44, hh = Math.max(230, 46 + L.h + 18);
box(hx, y1, hw, hh, "pk-hall", 8);
T(hx + 14, y1 + 20, "pk-lane", `PRESS HALL · ${r.D} DUPLEX SYSTEM${r.D === 1 ? "" : "S"} + ${r.C} CLASSIC${r.C === 1 ? "" : "S"} · ${r.P} PRESSES · ~${f0(r.floor)} M² PRESS FLOOR`);
o.push(`<g transform="translate(${hx + hw - 300},${y1 + 10})"><rect width="10" height="10" class="pk-ex50"/><text x="14" y="9" class="pk-s">EX 50</text>` +
`<rect x="62" width="10" height="10" class="pk-ex35"/><text x="76" y="9" class="pk-s">EX 35 colour</text>` +
`<circle cx="160" cy="5" r="5" class="pk-op"/><text x="169" y="9" class="pk-s">operator</text>` +
`<rect x="225" width="10" height="10" class="pk-trim"/><text x="239" y="9" class="pk-s">trimmer</text></g>`);
if (!items.length) T(hx + hw / 2, y1 + hh / 2, "pk-s", "no presses", "middle");
items.forEach(([k, n], i) => {
const [px, py] = L.pos[i], tx = hx + 15 + px, ty = y1 + 34 + py;
if (k === "D") o.push(`<g transform="translate(${tx},${ty}) scale(${sc})">` +
`<rect width="150" height="104" rx="6" class="pk-cell"/>` +
`<path d="M60,24 L42,36 M74,26 L108,36" class="pk-feed"/>` +
`<rect x="-15" y="-15" width="30" height="30" class="pk-press" transform="translate(32,46) rotate(45)"/>` +
`<rect x="-15" y="-15" width="30" height="30" class="pk-press" transform="translate(118,46) rotate(45)"/>` +
`<rect x="57" y="10" width="14" height="30" rx="2" class="pk-ex50"/>` +
`<rect x="77" y="14" width="11" height="24" rx="2" class="pk-ex35"/>` +
`<circle cx="73" cy="57" r="5.5" class="pk-op"/>` +
`<rect x="48" y="66" width="13" height="13" rx="1" class="pk-trim"/>` +
`<text x="75" y="96" class="pk-s" text-anchor="middle">Duplex ${n} · ${pct(r.dUse)}</text></g>`);
else o.push(`<g transform="translate(${tx},${ty}) scale(${sc})">` +
`<rect width="84" height="104" rx="6" class="pk-cell"/>` +
`<rect x="9" y="16" width="11" height="30" rx="2" class="pk-ex50"/>` +
`<rect x="28" y="22" width="36" height="36" rx="3" class="pk-press"/>` +
`<circle cx="33" cy="27" r="3.5" class="pk-col"/><circle cx="59" cy="27" r="3.5" class="pk-col"/><circle cx="46" cy="53" r="3.5" class="pk-col"/>` +
`<circle cx="66" cy="73" r="7" class="pk-disc"/>` +
`<text x="42" y="96" class="pk-s" text-anchor="middle">Classic ${n} · ${pct(r.cUse)}</text></g>`);
});
// compound in
const cy = y1 + 20;
box(17, cy, 130, 112, "pk-box");
T(25, cy + 17, "pk-t", "Compound"); T(25, cy + 33, "pk-s", "colour store and silos");
T(25, cy + 56, "pk-v", `${f0(r.compound)} t a year`);
T(25, cy + 74, "pk-s", `${f0(r.extKgH)} kg/h extruded`);
T(25, cy + 90, "pk-s", `trim back: ${f0(r.trimT)} t a year`);
arrow(149, cy + 56, hx - 2, cy + 56);
// stamper rack, fed from the coin press above, into the hall
const rx0 = 1053, ry = y1 + 6;
arrow(17 + 7 * (bw + gp) + bw / 2, y0 + bh + 2, 17 + 7 * (bw + gp) + bw / 2, ry - 2);
box(rx0, ry, 130, 88, "pk-box");
T(rx0 + 8, ry + 17, "pk-t", "Stamper rack"); T(rx0 + 8, ry + 33, "pk-s", r.galvIn ? "sets to the presses" : "bought-in sets");
T(rx0 + 8, ry + 54, "pk-v", `${f1(f.stampers)} a day`);
T(rx0 + 8, ry + 72, "pk-s", `${r.perSide} per side a title`);
arrow(rx0 - 2, ry + 44, hx + hw + 2, ry + 44);
// finished records out to finishing
const fy = ry + 108;
box(rx0, fy, 130, 104, "pk-box");
T(rx0 + 8, fy + 17, "pk-t", "Finishing"); T(rx0 + 8, fy + 33, "pk-s", "cooling, QC, packing");
T(rx0 + 8, fy + 54, "pk-v", `${f0(r.X / days)} a day`);
T(rx0 + 8, fy + 72, "pk-s", `${r.fin} ${r.fin === 1 ? "person" : "people"} per shift`);
T(rx0 + 8, fy + 88, "pk-s", `${f0(r.perHour)} records an hour`);
arrow(hx + hw + 2, fy + 52, rx0 - 2, fy + 52);
// 3. utilities, piped up into the hall
const y3 = y1 + hh + 46, uw = 158, ug = (hw - 5 * uw) / 4;
T(hx, y3 - 12, "pk-lane", "CENTRAL UTILITIES");
const U = [
["Central hydraulics", `${f0(r.hyd.flow)} l/min`, `${f1(r.hyd.motor)} kW motors`, `${f0(r.hyd.oil)} l oil, up to 200 bar`, "pk-hyd", false],
["Steam boiler", `${f0(r.steam[1])} kg/h peak`, `from ${f0(r.steam[0])} kg/h, 10 bar`, `${r.nb} × ${f0(A.boilerMod)} kg/h ${S.steamFuel === "biomass" ? "biomass" : "electric"}`, "pk-steam", r.nb > A.modRooms],
["Cooling", `${f0(r.coolPeak)} kW peak`, `${f0(r.water[0])} to ${f0(r.water[1])} l/min`, `${r.nc} × ${f0(A.chillerMod)} kW chillers`, "pk-cool", r.nc > A.modRooms],
["Compressed air", `${f0(r.air)} m³/h`, "6 to 10 bar, dry", `${f1(r.compKW)} kW compressors`, "pk-air", false],
["Electrical", `${f0(r.kva)} kVA`, `${f0(r.kw)} kW average`, `${f0(r.mwh)} MWh a year`, "pk-pow", false],
];
U.forEach(([t, v, s1, s2, cls, warn], i) => {
const x = hx + i * (uw + ug);
o.push(`<path d="M${x + uw / 2},${y3} L${x + uw / 2},${y1 + hh}" class="pk-pipe ${cls}"/>`);
box(x, y3, uw, 96, "pk-box" + (warn ? " warn" : ""));
T(x + 8, y3 + 17, "pk-t", t);
T(x + 8, y3 + 42, "pk-v", v);
T(x + 8, y3 + 62, "pk-s", s1);
T(x + 8, y3 + 78, "pk-s", s2);
});
const H = y3 + 96 + 12;
const svg = $("park");
svg.setAttribute("viewBox", `0 0 1200 ${H}`);
svg.innerHTML = o.join("");
}
// ---------------------------------------------------------------- inputs and state
const NUM = ["target", "dMin", "cMin", "dFleet", "cFleet", "shifts", "hrs", "days", "eff", "rateD", "rateC", "specF", "priceStd", "premSpec", "titleFee", "exportShare", "stSupply"];
function syncA(id, except) {
const el = $("a_" + id); if (el && el !== except) el.value = A[id];
for (const m of document.querySelectorAll(`[data-a="${id}"]`)) if (m !== except) m.value = A[id];
}
function syncInputs() {
for (const k of NUM) $(k).value = S[k];
$("targetR").value = S.target;
$("spec").value = S.spec;
for (const [, id] of AS) syncA(id);
}
function buildAssumptions() {
let last = "", html = `<thead><tr><th>Figure</th><th class="n">Value</th><th>Unit</th><th>Basis</th></tr></thead><tbody>`;
const inp = (id, label) => `<input type="number" id="a_${id}" step="any" aria-label="${esc(label)}">`;
for (const [g, id, label, , unit, kind, src, pair] of AS) {
if (pair === 2) continue; // the high end sits in the low end's row
if (g !== last) { html += `<tr class="grp"><td colspan="4">${esc(g)}</td></tr>`; last = g; }
const hi = pair === 1 ? id.replace(/Lo$/, "Hi") : "";
html += `<tr data-id="${pair === 1 ? id + "," + hi : id}"><td>${esc(label)}</td><td class="n">` +
(pair === 1 ? `${inp(id, label + ", low")} to ${inp(hi, label + ", high")}` : inp(id, label)) + `</td>` +
`<td>${esc(unit)}</td><td class="src"><span class="badge ${kind}">${KIND[kind]}</span> ${esc(src)}</td></tr>`;
}
$("asT").innerHTML = html + "</tbody>";
}
function writeHash() {
const p = [];
for (const k in S0) if (S[k] !== S0[k]) p.push(k + "=" + encodeURIComponent(S[k]));
for (const k in A0) if (A[k] !== A0[k]) p.push("a." + k + "=" + encodeURIComponent(A[k]));
const h = p.length ? "#" + p.join("&") : "";
if (location.hash !== h) history.replaceState(null, "", location.pathname + location.search + h);
}
function readHash() {
for (const part of location.hash.replace(/^#/, "").split("&")) {
if (!part) continue;
const [k, v0] = part.split("="), v = decodeURIComponent(v0 || "");
if (k.startsWith("a.")) { const id = k.slice(2); if (id in A0 && v !== "" && isFinite(+v)) A[id] = +v; }
else if (k in S0) S[k] = typeof S0[k] === "number" ? (v !== "" && isFinite(+v) ? +v : S0[k]) : v;
}
for (const k in ENUM) if (!ENUM[k].includes(S[k])) S[k] = S0[k];
}
function num(el, fallback) { const v = parseFloat(el.value); return isFinite(v) ? v : fallback; }
buildAssumptions();
buildCfg();
readHash();
syncInputs();
for (const k of NUM) $(k).addEventListener("input", e => { S[k] = num(e.target, S0[k]); if (k === "target") $("targetR").value = S.target; render(); });
$("targetR").addEventListener("input", e => { S.target = +e.target.value; $("target").value = S.target; render(); });
$("spec").addEventListener("input", e => { S.spec = +e.target.value; render(); });
$("case").addEventListener("click", e => {
const bt = e.target.closest("button[data-v]"), v = bt && bt.dataset.v; if (!v || !CASES[v]) return;
Object.assign(S, CASES[v].S); Object.assign(A, CASES[v].A); syncInputs(); render();
});
// every segmented switch; a disabled option never changes the state
for (const g of Object.keys(ENUM)) { const el = $(g); if (el) el.addEventListener("click", e => {
const bt = e.target.closest("button[data-v]"); if (!bt || !el.contains(bt) || bt.disabled) return;
const v = bt.dataset.v; if (!ENUM[g].includes(v)) return;
if (g === "mode" && v === "fleet" && S.mode === "target") { const { r } = scenario(); S.dFleet = r.D; S.cFleet = r.C; syncInputs(); }
S[g] = v; render();
}); }
$("presets").addEventListener("click", e => {
const bt = e.target.closest("button[data-p]"); if (!bt || !PRESETS[bt.dataset.p]) return;
Object.assign(S, PRESETS[bt.dataset.p]); syncInputs(); render();
});
$("asT").addEventListener("input", e => {
const id = e.target.id.replace(/^a_/, ""); if (!(id in A0)) return;
A[id] = num(e.target, A0[id]); syncA(id, e.target); render();
});
document.addEventListener("input", e => {
const id = e.target.dataset && e.target.dataset.a; if (!id || !(id in A0) || e.target.disabled) return;
A[id] = num(e.target, A0[id]); syncA(id, e.target); render();
});
$("ladT").addEventListener("click", e => {
const tr = e.target.closest("tr.lad"); if (!tr) return;
const [, d, c] = LADDER[+tr.dataset.i];
S.mode = "fleet"; S.dFleet = d; S.cFleet = c; syncInputs(); render();
});
$("reset").addEventListener("click", () => { S = { ...S0 }; A = { ...A0 }; syncInputs(); render(); });
addEventListener("hashchange", () => { S = { ...S0 }; A = { ...A0 }; readHash(); syncInputs(); render(); });
$("copy").addEventListener("click", () => {
const done = () => { $("copy").textContent = "Link copied"; setTimeout(() => { $("copy").textContent = "Copy link to this scenario"; }, 1600); };
if (navigator.clipboard) navigator.clipboard.writeText(location.href).then(done, done); else done();
});
render();
// hooks for headless checks
window.__fc = { get S() { return S; }, get A() { return A; }, get iso() { return ISO; }, A0, S0, AS, PRESETS, scenario, econ, capexOf, cashView, render, syncInputs };
(function(){
var b=document.getElementById("themeBtn"),de=document.documentElement;
function set(t,save){de.setAttribute("data-theme",t);if(save){try{localStorage.setItem("cnx-theme",t)}catch(e){}}
b.textContent=t==="light"?"☾ Dark mode":"☀ Light mode";b.title="Switch to "+(t==="light"?"dark":"light")+" mode";}
set(de.getAttribute("data-theme")||"dark",false);
b.addEventListener("click",function(){set(de.getAttribute("data-theme")==="light"?"dark":"light",true);b.blur();});
var mq=window.matchMedia&&matchMedia("(prefers-color-scheme: light)");
if(mq&&mq.addEventListener)mq.addEventListener("change",function(e){var s=null;try{s=localStorage.getItem("cnx-theme")}catch(_){}if(!s)set(e.matches?"light":"dark",false);});
})();
</script>
</body>
</html>