{"id":13171,"date":"2026-05-19T15:00:20","date_gmt":"2026-05-19T13:00:20","guid":{"rendered":"https:\/\/geopard.tech\/?page_id=13171"},"modified":"2026-06-15T13:42:58","modified_gmt":"2026-06-15T11:42:58","slug":"leitlinien","status":"publish","type":"page","link":"https:\/\/geopard.tech\/de\/guidance-lines\/","title":{"rendered":"Leitliniensimulator"},"content":{"rendered":"\n<!--\n  Compact hero block for \/guidance-lines\/ \u2014 pasted INLINE into page 13171\n  (not a shared reusable block, since block 13098 is field-explorer-specific\n  despite the generic name).\n\n  Style mirrors block 13098: green-cream radial-gradient backdrop, eyebrow\n  pill, headline with orange\u2192green accent, 5 feature chips ending with a\n  \"data stays in your browser\" trust chip.\n\n  If you reset the page content via WP admin and need to rebuild it: paste\n  this block in a Custom HTML block as the FIRST block of the page, then\n  the build\/embed_only.html as the SECOND block, then Reusable Block 13099\n  as the THIRD block.\n-->\n<div class=\"gp-lead\">\n<style>\n\/* Rule \u00a78 \u2014 hide the geopard child theme's bare <h1> page title so the\n   tool's custom hero is the first thing the user sees. The theme renders\n   the page title as #simple_page > .container > h1 (no class), so the\n   standard .entry-title \/ .page-title \/ .wp-block-post-title selectors\n   don't reach it. *\/\n#simple_page > .container > h1{display:none !important}\n.gp-lead{font-family:var(--wp--preset--font-family--nunito,\"Nunito\",system-ui,-apple-system,Segoe UI,sans-serif);color:#212121;max-width:1700px;margin:0 auto;padding:0 8px}\n.gp-lead h1{font-family:var(--wp--preset--font-family--poppins,\"Poppins\",system-ui,sans-serif);font-weight:800;color:#0e3a1c;line-height:1.15;letter-spacing:-0.018em;margin:0 auto 12px;font-size:1.95rem;max-width:880px}\n.gp-lead h1 .accent{background:linear-gradient(120deg,#f76a0c 0%,#15701e 100%);-webkit-background-clip:text;background-clip:text;color:transparent}\n\/* Hero: keep the wide-canvas backdrop (1700 px max via .gp-lead) but\n   center the text + chip column inside it so a 1920 \u00d7 1080 viewport\n   doesn't leave the entire right half empty. The radial gradients in\n   the background still produce asymmetric green\/orange glows that\n   add visual interest at the edges. *\/\n.gp-lead .gpl-hero{position:relative;background:radial-gradient(900px 360px at 0% -10%,rgba(74,222,128,0.18),transparent 60%),radial-gradient(700px 320px at 105% 110%,rgba(247,106,12,0.10),transparent 60%),linear-gradient(180deg,#fbfcf6 0%,#f1f6e8 100%);border:1px solid #d6e2c5;border-radius:20px;padding:28px 36px 24px;margin:6px 0 24px;overflow:hidden;box-shadow:0 12px 32px rgba(20,83,40,0.06),0 0 0 1px rgba(255,255,255,0.55) inset;text-align:center}\n.gp-lead .gpl-hero-eyebrow{display:inline-flex;align-items:center;gap:8px;font-family:var(--wp--preset--font-family--poppins,\"Poppins\",sans-serif);font-size:.74rem;font-weight:700;letter-spacing:.14em;text-transform:uppercase;color:#15701e;background:#fff;border:1px solid #cfe4d4;border-radius:999px;padding:6px 12px;margin:0 0 14px;box-shadow:0 1px 2px rgba(20,83,40,0.05)}\n.gp-lead .gpl-hero-eyebrow::before{content:\"\";width:7px;height:7px;border-radius:50%;background:#22c55e;box-shadow:0 0 0 3px rgba(34,197,94,0.22)}\n.gp-lead .gpl-hero .lede{font-size:1.02rem;line-height:1.55;color:#243024;margin:0 auto;max-width:760px;font-weight:400}\n.gp-lead .gpl-hero .lede strong{color:#0e3a1c;font-weight:600}\n.gp-lead .gpl-hero-features{display:flex;flex-wrap:wrap;gap:8px;margin:18px 0 0;justify-content:center}\n.gp-lead .gpl-hero-feat{display:inline-flex;align-items:center;gap:8px;background:#fff;border:1px solid #d8e6d8;color:#1c4a2a;font-size:.86rem;font-weight:600;padding:6px 12px;border-radius:999px;font-family:var(--wp--preset--font-family--poppins,\"Poppins\",sans-serif);box-shadow:0 1px 2px rgba(20,83,40,0.04)}\n.gp-lead .gpl-hero-feat::before{content:\"\\2713\";width:16px;height:16px;flex:0 0 16px;background:#22c55e;color:#fff;border-radius:50%;font-size:10px;font-weight:800;line-height:16px;text-align:center}\n.gp-lead .gpl-hero-feat.is-trust{background:transparent;border-color:transparent;color:#3a4a3a;padding:6px 4px;box-shadow:none}\n.gp-lead .gpl-hero-feat.is-trust::before{content:\"\\1F512\";width:auto;height:auto;background:transparent;color:#15701e;font-size:13px;line-height:1}\n@media (max-width:1199px){\n  .gp-lead .gpl-hero{padding:24px 28px}\n  .gp-lead h1{font-size:1.75rem}\n}\n@media (max-width:760px){\n  .gp-lead{padding:0 12px}\n  .gp-lead .gpl-hero{padding:20px 18px;margin:4px 0 16px;border-radius:16px}\n  .gp-lead .gpl-hero-eyebrow{font-size:.66rem;letter-spacing:.12em;padding:5px 10px;margin-bottom:10px}\n  .gp-lead h1{font-size:1.35rem;line-height:1.2;margin-bottom:8px}\n  .gp-lead .gpl-hero .lede{font-size:.95rem;line-height:1.5}\n  .gp-lead .gpl-hero-features{gap:6px;margin-top:12px}\n  .gp-lead .gpl-hero-feat{font-size:.78rem;padding:5px 10px}\n}\n@media (max-width:480px){\n  .gp-lead{padding:0 8px}\n  .gp-lead .gpl-hero{padding:16px 14px;border-radius:12px;margin:2px 0 12px}\n  .gp-lead h1{font-size:1.15rem;letter-spacing:-0.01em}\n  .gp-lead .gpl-hero .lede{font-size:.86rem;line-height:1.45}\n  .gp-lead .gpl-hero-feat{font-size:.72rem;padding:4px 8px}\n  .gp-lead .gpl-hero-feat.is-trust{padding:4px 2px}\n}\n<\/style>\n<section class=\"gpl-hero\">\n  <div class=\"gpl-hero-eyebrow\">Guidance Lines Simulator \u00b7 Free \u00b7 No signup<\/div>\n  <h1>5 drive plans. Pick the one that <span class=\"accent\">saves the most fuel.<\/span><\/h1>\n  <p class=\"lede\">Drop your boundary + AB lines or pick a sample \u2014 we score <strong>AB Straight \u00b7 AB Curve \u00b7 Boundary \u00b7 Topography \u00b7 Auto-blocks<\/strong> on coverage, fuel, and annual ROI, then play back the winner.<\/p>\n  <div class=\"gpl-hero-features\">\n    <span class=\"gpl-hero-feat\">5 approaches scored<\/span>\n    <span class=\"gpl-hero-feat\">Annual ROI<\/span>\n    <span class=\"gpl-hero-feat\">Your lines + boundary<\/span>\n    <span class=\"gpl-hero-feat\">Slope-aware<\/span>\n    <span class=\"gpl-hero-feat is-trust\">Data stays in browser<\/span>\n  <\/div>\n<\/section>\n<\/div>\n\n\n\n<div class=\"gpl-wrap\" id=\"gpl-root\">\n<style>\n.gpl-wrap{all:initial;display:block;font-family:\"Nunito\",system-ui,-apple-system,sans-serif;font-size:13px;line-height:1.45;color:#212121;background:#fafbf4;border:1px solid #eeeeee;border-radius:12px;overflow:hidden;max-width:1700px;margin:0 auto;box-shadow:0 4px 20px rgba(20,83,40,.05)}\n.gpl-wrap *,.gpl-wrap *::before,.gpl-wrap *::after{box-sizing:border-box}\n.gpl-wrap .gpl-top{display:flex;align-items:center;padding:14px 18px;border-bottom:1px solid #eeeeee;background:#ffffff;gap:24px;flex-wrap:wrap}\n.gpl-wrap .gpl-brand{display:flex;align-items:center;gap:12px;min-width:0;flex:0 0 auto}\n.gpl-wrap .gpl-top .gpl-cta{margin-left:auto}\n.gpl-wrap .gpl-logo{width:28px;height:28px;border-radius:7px;background:linear-gradient(135deg,#145328 0%,#1a7951 100%);display:flex;align-items:center;justify-content:center;color:#fff;font-weight:700;font-size:14px;flex:none}\n.gpl-wrap .gpl-h1{font-size:15px;font-weight:600;letter-spacing:-.01em;font-family:\"Poppins\",system-ui,sans-serif}\n.gpl-wrap .gpl-chip{display:inline-flex;align-items:center;gap:6px;font-family:\"DM Mono\",ui-monospace,monospace;font-size:11px;padding:2px 8px;border-radius:10px;background:#fafbf4;color:#4c6066;border:1px solid #eeeeee;margin-top:3px}\n.gpl-wrap .gpl-chip .gpl-dot{width:6px;height:6px;border-radius:50%;background:#15701e;flex:none}\n.gpl-wrap .gpl-cta{background:#15701e;color:#fff;border:0;padding:9px 14px;border-radius:7px;font-weight:600;font-size:13px;cursor:pointer;font-family:inherit;display:inline-flex;align-items:center;gap:7px;text-decoration:none;transition:background .15s}\n.gpl-wrap .gpl-cta:hover{background:#1b7a2a}\n.gpl-wrap .gpl-grid{display:grid;grid-template-columns:var(--gpl-left-w,320px) 5px 1fr 5px var(--gpl-right-w,320px);grid-template-areas:\"left splitter main splitter-right right\";grid-template-rows:820px;min-height:820px;max-height:880px}\n.gpl-wrap .gpl-side{grid-area:left;background:#ffffff;border-right:1px solid #eeeeee;padding:5px 9px;display:flex;flex-direction:column;gap:1px;min-width:0;overflow-y:auto;max-height:820px}\n.gpl-wrap .gpl-splitter{grid-area:splitter;background:transparent;cursor:col-resize;position:relative;user-select:none;-webkit-user-select:none;touch-action:none}\n.gpl-wrap .gpl-splitter::before{content:\"\";position:absolute;left:50%;top:50%;width:1px;height:34px;transform:translate(-50%,-50%);background:#dddddd;transition:background .15s,width .15s}\n.gpl-wrap .gpl-splitter:hover::before,.gpl-wrap .gpl-splitter.is-drag::before{background:#15701e;width:3px;border-radius:2px;height:48px}\n.gpl-wrap .gpl-splitter-right{grid-area:splitter-right;background:transparent;cursor:col-resize;position:relative;user-select:none;-webkit-user-select:none;touch-action:none}\n.gpl-wrap .gpl-splitter-right::before{content:\"\";position:absolute;left:50%;top:50%;width:1px;height:34px;transform:translate(-50%,-50%);background:#dddddd;transition:background .15s,width .15s}\n.gpl-wrap .gpl-splitter-right:hover::before,.gpl-wrap .gpl-splitter-right.is-drag::before{background:#15701e;width:3px;border-radius:2px;height:48px}\n.gpl-wrap .gpl-main{grid-area:main;position:relative;background:#fafbf4;display:flex;flex-direction:column;min-height:0}\n.gpl-wrap .gpl-main > .gpl-fbar{position:relative;flex:0 0 auto;top:auto;left:auto;right:auto;margin:0;border-radius:0;border:0;border-bottom:1px solid #e8ebe2;background:#ffffff;backdrop-filter:none;box-shadow:none;z-index:auto}\n.gpl-wrap .gpl-main > .gpl-canvas-shell{position:relative;flex:1 1 auto;min-height:0;overflow:hidden}\n.gpl-wrap .gpl-side-right{grid-area:right;background:#ffffff;border-left:1px solid #eeeeee;padding:8px 8px;display:flex;flex-direction:column;gap:5px;min-width:0;overflow-y:auto;max-height:820px}\n\/* Plan view toggle (left sidebar, top) \u2014 Proposed vs Your current *\/\n.gpl-wrap .gpl-planview-toggle{display:flex;gap:4px;background:#f5f5f5;border-radius:6px;padding:3px;margin-top:4px}\n.gpl-wrap .gpl-planview-tab{flex:1;background:transparent;border:0;color:#4c6066;font-family:inherit;font-size:11.5px;font-weight:600;padding:7px 8px;border-radius:4px;cursor:pointer;transition:all .12s}\n.gpl-wrap .gpl-planview-tab:hover{color:#15701e}\n.gpl-wrap .gpl-planview-tab.is-on{background:#15701e;color:#ffffff}\n.gpl-wrap .gpl-planview-tab.is-on[data-planview=\"uploaded\"]{background:#2166c8}\n\/* Compare All moved below the main grid, full width single column. *\/\n.gpl-wrap .gpl-below{display:block;padding:16px 18px;background:#ffffff;border-top:1px solid #eeeeee}\n.gpl-wrap .gpl-below .gpl-card{margin:0}\n.gpl-wrap .gpl-cmp-hint{font-size:11px;color:#4c6066;background:#f5faf7;border:1px dashed #c5dccd;border-radius:5px;padding:8px 10px;margin-top:8px;line-height:1.4}\n\/* Responsive side-panel widths \u2014 graduated descent so the canvas keeps a\n   usable working area at every common viewport. Above 1400 px we use the\n   full 280 px side panels; from 1400 down we slim them progressively until\n   the 820 px stack-vertically breakpoint takes over. *\/\n@media (max-width:1399px){\n  .gpl-wrap .gpl-grid{grid-template-columns:var(--gpl-left-w,280px) 5px 1fr 5px var(--gpl-right-w,280px)}\n}\n@media (max-width:1199px){\n  .gpl-wrap .gpl-grid{grid-template-columns:var(--gpl-left-w,240px) 5px 1fr 5px var(--gpl-right-w,240px)}\n  \/* Tooltip narrower on narrow panels so it doesn't overflow the scroll\n     container. The 230 px default would overshoot a 220 px panel by 10 px. *\/\n  .gpl-wrap .gpl-info-ico::after{width:200px}\n}\n@media (max-width:1023px){\n  .gpl-wrap .gpl-grid{grid-template-columns:var(--gpl-left-w,200px) 5px 1fr 5px var(--gpl-right-w,210px)}\n  .gpl-wrap .gpl-info-ico::after{width:180px;font-size:10.5px}\n  \/* Compare All grid shrinks too \u2014 its fixed-width number columns were\n     consuming most of a 190 px side panel. *\/\n  .gpl-wrap .gpl-cmp{overflow-x:auto}\n  .gpl-wrap .gpl-cmp-row{grid-template-columns:minmax(130px,1.4fr) repeat(10, minmax(64px, 1fr));font-size:11px;gap:6px;padding:8px 10px;min-width:920px}\n  .gpl-wrap .gpl-r-row{padding:0}\n  .gpl-wrap .gpl-r-k{font-size:9.5px}\n  .gpl-wrap .gpl-r-v{font-size:11.5px}\n  \/* Tighter approach-label padding so the Best badge + info icon stay\n     on one line. *\/\n  .gpl-wrap .gpl-approach label{padding:6px 8px;font-size:12px;min-height:30px}\n}\n.gpl-wrap .gpl-canvas{display:block;width:100%;height:100%;cursor:default;background-color:#fafbf4;background-image:linear-gradient(rgba(20,83,40,0.07) 1px,transparent 1px),linear-gradient(90deg,rgba(20,83,40,0.07) 1px,transparent 1px),linear-gradient(rgba(20,83,40,0.04) 1px,transparent 1px),linear-gradient(90deg,rgba(20,83,40,0.04) 1px,transparent 1px);background-size:80px 80px,80px 80px,16px 16px,16px 16px;background-position:0 0,0 0,0 0,0 0}\n.gpl-wrap .gpl-copyright{position:absolute;right:8px;bottom:6px;font-family:\"Poppins\",system-ui,sans-serif;font-size:10px;color:rgba(20,83,40,0.55);letter-spacing:.02em;pointer-events:none;background:rgba(250,251,244,0.72);padding:2px 6px;border-radius:4px;font-weight:600;z-index:2}\n.gpl-wrap .gpl-top-fields{display:flex;align-items:center;gap:6px;flex-wrap:wrap;font-family:\"Poppins\",system-ui,sans-serif}\n.gpl-wrap .gpl-top-fields-lbl{font-size:9.5px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:#4c6066;flex:none}\n.gpl-wrap .gpl-top-fields .gpl-field-picker{display:flex;gap:3px;flex:0 0 auto}\n.gpl-wrap .gpl-top-fields .gpl-fld{flex-direction:row;padding:4px 9px;font-size:11px;min-height:28px;align-items:center;line-height:1}\n.gpl-wrap .gpl-top-fields .gpl-fld-sub{display:none}\n\/* Merged \"Run on your field\" CTA \u2014 the import button IS the run-on-your-\n   field action (free, in-browser, no signup), so it's styled as the\n   prominent green CTA rather than a quiet dashed upload link. is-success\n   \/ is-error states (set by showUploadStatus) still override the bg. *\/\n.gpl-wrap .gpl-top-fields .gpl-upload{margin-top:0;padding:7px 14px;min-height:32px;font-size:12px;font-weight:700;align-items:center;line-height:1;background:#15701e;color:#fff;border:0;border-radius:7px;box-shadow:0 1px 3px rgba(20,83,40,0.18)}\n.gpl-wrap .gpl-top-fields .gpl-upload:hover{background:#1b7a2a;border:0}\n.gpl-wrap .gpl-top-fields .gpl-upload.is-success{background:#15701e;color:#fff;border:0}\n.gpl-wrap .gpl-top-fields .gpl-upload.is-error{background:#fef2f2;color:#dc2626;border:1px solid #fca5a5}\n.gpl-wrap .gpl-top-fields .gpl-upload-txt{font-size:12px}\n.gpl-wrap .gpl-top-fields .gpl-upload-ico{font-size:13px}\n.gpl-wrap .gpl-top-fields .gpl-import-nav{margin-top:0;gap:4px}\n.gpl-wrap .gpl-top-fields .gpl-imp-btn{width:26px;height:26px;font-size:13px}\n.gpl-wrap .gpl-top-fields .gpl-imp-lbl{font-size:10px;min-width:46px}\n.gpl-wrap .gpl-card h3{font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:#4c6066;margin:0 0 1px 0;font-family:\"Poppins\",system-ui,sans-serif;padding-top:2px;border-top:1px solid #f2f2f2}\n.gpl-wrap .gpl-card:first-child h3{padding-top:0;border-top:0}\n\/* Pulsing attention ring \u2014 fires when a layout warning surfaces. Catches the\n   user's eye on the specific card they need to act on (Headland \/ Turnarounds\n   \/ AB line \/ Approach). Ring stays bright for ~1.4 s \u00d7 3 cycles, then fades to\n   a soft glow so the user can still spot it later if they look away mid-fix. *\/\n.gpl-wrap .gpl-card.gpl-card-attention{position:relative;animation:gplPulse 1.4s ease-in-out 0s 3;border-radius:7px}\n.gpl-wrap .gpl-card.gpl-card-attention h3{color:#c2410c}\n@keyframes gplPulse{\n  0%{box-shadow:0 0 0 0 rgba(247,106,12,0.55),0 0 0 0 rgba(247,106,12,0.35)}\n  35%{box-shadow:0 0 0 4px rgba(247,106,12,0.45),0 0 14px 6px rgba(247,106,12,0.30)}\n  100%{box-shadow:0 0 0 2px rgba(247,106,12,0.20),0 0 0 0 rgba(247,106,12,0)}\n}\n.gpl-wrap .gpl-card{display:flex;flex-direction:column;gap:2px;padding:0}\n.gpl-wrap .gpl-approach{display:flex;flex-direction:column;gap:2px}\n.gpl-wrap .gpl-approach label{display:flex;align-items:center;gap:6px;padding:4px 8px;border:1px solid #e1e8dd;border-radius:6px;cursor:pointer;background:#ffffff;font-size:12px;transition:transform .12s ease,border-color .12s ease,background .12s ease,box-shadow .12s ease;min-height:28px;line-height:1.15;font-weight:500;color:#1c4a2a;box-shadow:0 1px 2px rgba(20,83,40,0.04)}\n.gpl-wrap .gpl-approach label:hover{border-color:#15701e;background:linear-gradient(180deg,#fafbf4 0%,#f0f6e9 100%);transform:translateY(-1px);box-shadow:0 2px 6px rgba(20,83,40,0.10)}\n.gpl-wrap .gpl-approach label:active{transform:translateY(0);box-shadow:0 1px 2px rgba(20,83,40,0.06)}\n.gpl-wrap .gpl-approach input[type=radio]{accent-color:#15701e;width:16px;height:16px;margin:0;flex:none}\n.gpl-wrap .gpl-approach label.is-on{border-color:#15701e;background:linear-gradient(180deg,#e7f5ec 0%,#dff5e6 100%);font-weight:700;color:#0e3a1c;box-shadow:0 0 0 2px rgba(34,197,94,0.12),0 2px 6px rgba(20,83,40,0.08)}\n.gpl-wrap .gpl-approach label > span:first-of-type{flex:1;min-width:0}\n\/* Inline select row \u2014 label + select on the same line so the Machine row\n   doesn't waste two rows of vertical space. *\/\n.gpl-wrap .gpl-select-row-inline{flex-direction:row;align-items:center;gap:6px;display:flex}\n.gpl-wrap .gpl-select-row-inline label{flex:none;font-size:10.5px;color:#4c6066;min-width:54px}\n.gpl-wrap .gpl-select-row-inline select{flex:1;min-width:0}\n\/* Two-column grid for paired numeric inputs (Width \/ L-per-km, Turn r \/ Buffer).\n   Each child .gpl-input-row collapses to label + input only, no fixed width on the\n   label so they share the row 50\/50. *\/\n.gpl-wrap .gpl-grid-2{display:grid;grid-template-columns:1fr 1fr;gap:6px}\n.gpl-wrap .gpl-grid-2 .gpl-input-row{margin:0}\n.gpl-wrap .gpl-grid-2 .gpl-input-row label{flex:none;min-width:0;font-size:10.5px;color:#4c6066}\n.gpl-wrap .gpl-grid-2 .gpl-input-row input{flex:1;min-width:0;width:auto}\n\/* AB-line direction: slider + tiny \u21ba refit button on one row *\/\n.gpl-wrap .gpl-ab-row{align-items:center;gap:8px}\n.gpl-wrap .gpl-ab-row .gpl-ab-auto{flex:none;width:28px;height:28px;padding:0;margin:0;font-size:14px;line-height:1;border-radius:6px;display:inline-flex;align-items:center;justify-content:center}\n.gpl-wrap .gpl-ab-coords{margin-top:8px;padding:8px 10px;background:#fafbf4;border:1px solid #e3e8e1;border-radius:6px}\n.gpl-wrap .gpl-ab-coords-title{font-size:10px;font-weight:700;color:#145328;text-transform:uppercase;letter-spacing:.05em;margin-bottom:6px}\n.gpl-wrap .gpl-ab-coords-row{display:flex;align-items:center;gap:5px;margin-bottom:5px}\n.gpl-wrap .gpl-ab-coords-lbl{flex:0 0 14px;font-weight:700;font-size:11px;color:#145328}\n.gpl-wrap .gpl-ab-coords input[type=number]{flex:1;min-width:0;padding:4px 6px;font-size:11px;font-family:\"DM Mono\",ui-monospace,monospace;border:1px solid #d6dcd0;border-radius:4px;background:#ffffff}\n.gpl-wrap .gpl-ab-coords input[type=number]:focus{border-color:#15701e;outline:none}\n.gpl-wrap .gpl-ab-coords-btn{margin-top:3px;padding:5px 10px;font-size:11px;font-weight:600;background:#15701e;color:#ffffff;border:0;border-radius:4px;cursor:pointer;width:100%}\n.gpl-wrap .gpl-ab-coords-btn:hover{background:#145328}\n.gpl-wrap .gpl-ab-coords-hint{margin-top:4px;font-size:10px;color:#4c6066;font-family:\"DM Mono\",ui-monospace,monospace;line-height:1.3;min-height:0}\n.gpl-wrap .gpl-ab-coords-hint.is-error{color:#dc2626}\n.gpl-wrap .gpl-ab-coords-hint.is-success{color:#15701e}\n.gpl-wrap .gpl-reco{display:none;font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#15701e;background:#dff5e6;padding:2px 6px;border-radius:4px;margin-left:6px;font-family:\"DM Mono\",ui-monospace,monospace}\n.gpl-wrap .gpl-reco.is-on{display:inline-block}\n\/* Info icon on each Approach row. Visible \u24d8-style badge that triggers a\n   styled tooltip on hover\/focus. Tooltip is positioned absolutely relative\n   to the icon and extends LEFTWARD (into the label area) so the side panel's\n   overflow:auto doesn't clip it. tabindex=0 (added in JS) lets keyboard\n   users + touch users tap-focus to reveal the tip. *\/\n.gpl-wrap .gpl-info-ico{display:inline-flex;align-items:center;justify-content:center;width:14px;height:14px;border-radius:50%;background:#e8eee8;color:#4c6066;font-family:\"DM Mono\",ui-monospace,monospace;font-size:9px;font-weight:700;font-style:italic;cursor:help;flex:none;margin-left:6px;position:relative;line-height:1;transition:background .12s,color .12s}\n.gpl-wrap .gpl-cmp-info{margin-left:4px;vertical-align:middle;text-transform:none;letter-spacing:0}\n.gpl-wrap .gpl-info-ico:hover,.gpl-wrap .gpl-info-ico:focus{background:#15701e;color:#fff;outline:none}\n\/* Tooltip drops BELOW the icon (was UP-LEFT, which got clipped by the side\n   panel's overflow-y:auto whenever the icon sat near the top of the panel).\n   Anchored to the icon's right edge so the 230 px-wide tip extends leftward\n   into the row instead of off the right side of the panel. Vertical\n   offset puts it just under the icon + arrow. *\/\n.gpl-wrap .gpl-info-ico::after{content:attr(data-tip);position:absolute;top:calc(100% + 8px);right:-6px;width:230px;background:#143a1c;color:#fff;padding:8px 10px;border-radius:6px;font-family:var(--wp--preset--font-family--nunito,\"Nunito\",sans-serif);font-size:11px;font-weight:400;font-style:normal;line-height:1.45;text-align:left;white-space:normal;letter-spacing:0;text-transform:none;pointer-events:none;opacity:0;transition:opacity .15s;z-index:50;box-shadow:0 6px 16px rgba(0,0,0,.18)}\n.gpl-wrap .gpl-info-ico::before{content:\"\";position:absolute;top:calc(100% + 3px);right:0;border:5px solid transparent;border-bottom-color:#143a1c;pointer-events:none;opacity:0;transition:opacity .15s;z-index:51}\n.gpl-wrap .gpl-info-ico:hover::after,.gpl-wrap .gpl-info-ico:focus::after,.gpl-wrap .gpl-info-ico:hover::before,.gpl-wrap .gpl-info-ico:focus::before{opacity:1}\n\/* Best badge now sits BEFORE the info icon. Both ride flex auto-margins:\n   the Name span (flex:1) absorbs all spare space, so Best + i naturally\n   anchor at the right edge of the row. *\/\n.gpl-wrap .gpl-input-row{display:flex;align-items:center;gap:5px}\n.gpl-wrap .gpl-input-row label{font-size:11px;color:#4c6066;flex:1}\n.gpl-wrap .gpl-input-row input{width:54px;padding:4px 6px;border:1px solid #eeeeee;border-radius:4px;font-family:inherit;font-size:13px;text-align:right;min-height:28px}\n.gpl-wrap .gpl-input-row .gpl-unit{font-size:10px;color:#4c6066;width:26px}\n.gpl-wrap .gpl-results{display:flex;flex-direction:column;gap:2px;font-family:\"DM Mono\",ui-monospace,monospace;font-size:11px;background:#fafbf4;padding:6px 8px;border-radius:5px;border:1px solid #eeeeee}\n.gpl-wrap .gpl-r-row{display:flex;justify-content:space-between;align-items:baseline;padding:1px 0}\n.gpl-wrap .gpl-r-k{color:#4c6066;font-size:10px}\n.gpl-wrap .gpl-r-v{font-weight:600;color:#212121;font-size:12px}\n\/* Right-panel inline input row (e.g. Diesel price field sitting in the\n   Analytics card). Keeps the same key-on-left, value-on-right alignment\n   as readonly rows but the \"value\" slot holds a compact number input +\n   the $\/L currency dropdown. *\/\n.gpl-wrap .gpl-r-input{align-items:center;padding:4px 0}\n.gpl-wrap .gpl-r-ctl{display:inline-flex;align-items:center;gap:5px}\n.gpl-wrap .gpl-r-ctl input{width:80px;padding:6px 8px;border:1px solid #d6d8d2;border-radius:5px;font-family:inherit;font-size:13px;text-align:right;min-height:32px;font-weight:700;color:#0e3a1c;max-width:none;background:#ffffff}\n.gpl-wrap .gpl-r-ctl input:focus{border-color:#15701e;outline:0;box-shadow:0 0 0 2px rgba(21,112,30,0.18)}\n.gpl-wrap .gpl-r-ctl select{padding:6px 6px;border:1px solid #d6d8d2;border-radius:5px;font-family:inherit;font-size:12px;background:#fafbf4;min-height:32px;width:62px;font-weight:600;color:#0e3a1c;max-width:none}\n.gpl-wrap .gpl-r-savings{font-size:11px;font-weight:600}\n.gpl-wrap .gpl-r-row.gpl-r-pri{border-top:1px solid #eeeeee;padding-top:6px;margin-top:2px}\n.gpl-wrap .gpl-r-section{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:#4c6066;padding:8px 0 2px 0;margin-top:4px;border-top:1px solid #eeeeee;font-family:\"Poppins\",system-ui,sans-serif}\n.gpl-wrap .gpl-r-section:first-child{border-top:0;margin-top:0;padding-top:0}\n.gpl-wrap .gpl-r-sub{padding-left:8px;color:#4c6066}\n.gpl-wrap .gpl-r-row .gpl-r-ctl input[type=\"number\"]{font-family:\"DM Mono\",ui-monospace,monospace}\n.gpl-wrap .gpl-r-row .gpl-r-ctl .gpl-unit{font-size:10px;color:#4c6066;margin-left:4px}\n.gpl-wrap .gpl-below-cmp h3{font-size:16px;font-weight:700;color:#145328;text-transform:none;letter-spacing:0;margin:0 0 4px 0;border-top:0;padding-top:0;font-family:\"Poppins\",system-ui,sans-serif}\n.gpl-wrap .gpl-cmp-help{font-size:12.5px;color:#4c6066;margin:0 0 14px 0;line-height:1.45}\n.gpl-wrap .gpl-cmp{margin-top:0;background:#ffffff;border:1px solid #e3e8e1;border-radius:10px;overflow:auto;box-shadow:0 1px 3px rgba(20,83,40,.04)}\n.gpl-wrap .gpl-cmp-row{display:grid;grid-template-columns:minmax(160px,1.7fr) repeat(10, minmax(80px, 1fr));align-items:center;padding:12px 16px;font-size:13px;border-bottom:1px solid #f0f2ec;gap:10px;transition:background .12s}\n.gpl-wrap .gpl-cmp-row:last-child{border-bottom:0}\n.gpl-wrap .gpl-cmp-row:hover:not(.gpl-cmp-head){background:#fafbf4}\n.gpl-wrap .gpl-cmp-row.gpl-cmp-head{font-size:11.5px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#5a6f60;background:linear-gradient(180deg,#fafbf4 0%,#f3f5ec 100%);font-family:\"Poppins\",system-ui,sans-serif;border-bottom:2px solid #d9e7dc;padding:14px 16px}\n.gpl-wrap .gpl-cmp-row.gpl-cmp-head:hover{background:linear-gradient(180deg,#fafbf4 0%,#f3f5ec 100%)}\n.gpl-wrap .gpl-cmp-name{font-family:\"Poppins\",system-ui,sans-serif;font-weight:600;color:#145328;font-size:13.5px}\n.gpl-wrap .gpl-cmp-val{text-align:right;font-family:\"DM Mono\",ui-monospace,monospace;font-size:13px;font-feature-settings:\"tnum\" 1;font-variant-numeric:tabular-nums}\n.gpl-wrap .gpl-cmp-row.is-best{background:linear-gradient(90deg,rgba(21,112,30,0.07) 0%,rgba(21,112,30,0.02) 100%);box-shadow:inset 3px 0 0 #15701e}\n.gpl-wrap .gpl-cmp-row.is-best:hover{background:linear-gradient(90deg,rgba(21,112,30,0.10) 0%,rgba(21,112,30,0.03) 100%)}\n.gpl-wrap .gpl-cmp-row.is-best .gpl-cmp-name::before{content:\"\u2605 \";color:#15701e;font-size:12px;margin-right:1px}\n.gpl-wrap .gpl-cmp-row.gpl-cmp-uploaded{background:rgba(33,102,200,0.06);border-top:1px solid #d6e4f2;box-shadow:inset 3px 0 0 #2166c8}\n.gpl-wrap .gpl-cmp-row.gpl-cmp-uploaded:hover{background:rgba(33,102,200,0.10)}\n.gpl-wrap .gpl-cmp-row.gpl-cmp-uploaded .gpl-cmp-name{color:#2166c8;font-weight:700}\n.gpl-wrap .gpl-cmp-sort{cursor:pointer;user-select:none;-webkit-user-select:none;display:inline-flex;align-items:center;justify-content:flex-end;gap:5px;padding:3px 6px;border-radius:5px;transition:background .12s,color .12s;text-decoration:none}\n.gpl-wrap .gpl-cmp-sort:hover{color:#15701e;background:#eaf3ed}\n.gpl-wrap .gpl-cmp-row.gpl-cmp-head .gpl-cmp-sort:first-child{justify-content:flex-start}\n.gpl-wrap .gpl-cmp-sort .gpl-cmp-arr{display:inline-block;width:11px;height:11px;font-size:9px;color:#a8b3a3;line-height:1;text-align:center;opacity:.6}\n.gpl-wrap .gpl-cmp-sort:hover .gpl-cmp-arr{opacity:1;color:#15701e}\n.gpl-wrap .gpl-cmp-sort .gpl-cmp-arr::before{content:\"\u21c5\"}\n.gpl-wrap .gpl-cmp-sort.is-sort-asc{color:#15701e;background:#dff5e6}\n.gpl-wrap .gpl-cmp-sort.is-sort-asc:hover{background:#cfecda}\n.gpl-wrap .gpl-cmp-sort.is-sort-asc .gpl-cmp-arr{opacity:1}\n.gpl-wrap .gpl-cmp-sort.is-sort-asc .gpl-cmp-arr::before{content:\"\u25b2\";color:#15701e}\n.gpl-wrap .gpl-cmp-sort.is-sort-desc{color:#15701e;background:#dff5e6}\n.gpl-wrap .gpl-cmp-sort.is-sort-desc:hover{background:#cfecda}\n.gpl-wrap .gpl-cmp-sort.is-sort-desc .gpl-cmp-arr{opacity:1}\n.gpl-wrap .gpl-cmp-sort.is-sort-desc .gpl-cmp-arr::before{content:\"\u25bc\";color:#15701e}\n.gpl-wrap .gpl-cmp-best{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:12px;padding:12px 16px;background:linear-gradient(90deg,#dff5e6 0%,#e9f7ee 60%,#f0faf3 100%);border:1px solid #b8e5c6;border-radius:8px;font-size:12.5px}\n.gpl-wrap .gpl-cmp-best-badge{font-size:10.5px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:#fff;background:#15701e;padding:4px 10px;border-radius:4px;font-family:\"DM Mono\",ui-monospace,monospace}\n.gpl-wrap .gpl-cmp-best-name{font-weight:700;color:#145328;font-size:13.5px}\n.gpl-wrap .gpl-cmp-best-why{color:#1e5d3a;font-size:12px;line-height:1.45;flex:1;min-width:200px}\n.gpl-wrap .gpl-cmp-best{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:8px;padding:8px 10px;background:linear-gradient(90deg,#dff5e6 0%,#e9f7ee 100%);border:1px solid #b8e5c6;border-radius:6px;font-size:11.5px}\n.gpl-wrap .gpl-cmp-best-badge{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#fff;background:#15701e;padding:3px 8px;border-radius:3px;font-family:\"DM Mono\",ui-monospace,monospace}\n.gpl-wrap .gpl-cmp-best-name{font-weight:700;color:#145328;font-size:12.5px}\n.gpl-wrap .gpl-cmp-best-why{color:#1e5d3a;font-size:11px;line-height:1.4;flex:1;min-width:160px}\n.gpl-wrap .gpl-cmp-row.is-best{background:#f5faf7}\n.gpl-wrap .gpl-cmp-row:last-child{border-bottom:0}\n.gpl-wrap .gpl-cmp-row.gpl-cmp-head{font-weight:600;color:#4c6066;text-transform:uppercase;letter-spacing:.04em;font-size:10px;background:#fafbf4}\n.gpl-wrap .gpl-cmp-name{font-family:\"DM Mono\",ui-monospace,monospace}\n.gpl-wrap .gpl-cmp-val{text-align:right;font-family:\"DM Mono\",ui-monospace,monospace}\n.gpl-wrap .gpl-cmp-row.gpl-cmp-current{background:transparent}\n\/* \"Your existing\" comparison row (uploaded guidance lines). Distinct blue\n   accent so the user can spot their existing plan vs the four generated\n   proposals. Hidden by default; recompute sets hidden=false when an upload\n   is active. *\/\n.gpl-wrap .gpl-cmp-row.gpl-cmp-uploaded{background:rgba(33,102,200,0.06);border-top:1px solid #d6e4f2}\n.gpl-wrap .gpl-cmp-row.gpl-cmp-uploaded .gpl-cmp-name{color:#2166c8;font-weight:700}\n.gpl-wrap .gpl-unit-toggle{display:inline-flex;gap:2px;background:#fafbf4;border:1px solid #eeeeee;border-radius:6px;padding:2px;margin-bottom:5px;align-self:flex-start}\n.gpl-wrap .gpl-unit-tab{background:transparent;border:0;padding:3px 8px;font-size:10px;font-family:\"DM Mono\",ui-monospace,monospace;font-weight:700;color:#4c6066;cursor:pointer;border-radius:4px;min-width:28px}\n.gpl-wrap .gpl-unit-tab.is-on{background:#15701e;color:#fff}\n.gpl-wrap .gpl-slider-row{display:flex;align-items:center;gap:6px}\n.gpl-wrap .gpl-range{flex:1;accent-color:#15701e;min-height:24px}\n.gpl-wrap .gpl-slider-val{font-family:\"DM Mono\",ui-monospace,monospace;font-size:10px;color:#4c6066;min-width:90px;text-align:right}\n.gpl-wrap .gpl-select-row{display:flex;flex-direction:column;gap:2px}\n.gpl-wrap .gpl-select-row label{font-size:10px;color:#4c6066}\n.gpl-wrap .gpl-select-row select{padding:4px 6px;border:1px solid #eeeeee;border-radius:4px;font-family:inherit;font-size:12px;background:#ffffff;min-height:32px}\n.gpl-wrap .gpl-cur-sel{margin-left:4px;padding:4px 6px;border:1px solid #eeeeee;border-radius:5px;font-family:inherit;font-size:11px;background:#ffffff;min-height:32px;width:46px}\n.gpl-wrap .gpl-hint{font-size:9px;color:#4c6066;font-family:\"DM Mono\",ui-monospace,monospace;margin:-1px 0 2px 0;line-height:1.3;font-style:italic}\n.gpl-wrap .gpl-field-picker{display:grid;grid-template-columns:1fr 1fr;gap:3px}\n.gpl-wrap .gpl-fld{background:#ffffff;border:1px solid #eeeeee;border-radius:5px;padding:3px 5px;font-family:inherit;font-size:10px;font-weight:600;cursor:pointer;color:#212121;display:flex;flex-direction:column;align-items:flex-start;gap:0;text-align:left;transition:all .12s;min-height:28px;line-height:1.1}\n.gpl-wrap .gpl-fld:hover{border-color:#1b7a2a;background:#fafbf4}\n.gpl-wrap .gpl-fld.is-on{border-color:#15701e;background:#fafbf4;font-weight:700}\n.gpl-wrap .gpl-fld-imported{border-color:#2166c8;background:#eef4fd;color:#2166c8}\n.gpl-wrap .gpl-fld-imported:hover{border-color:#1a4e9c;background:#dee9fa}\n.gpl-wrap .gpl-fld-imported.is-on{border-color:#1a4e9c;background:#dee9fa;color:#1a4e9c}\n.gpl-wrap .gpl-fld-imported::before{content:\"\u2934\";margin-right:5px;font-size:11px}\n.gpl-wrap .gpl-fld-sub{font-size:8px;color:#4c6066;font-weight:500;font-style:italic;text-transform:none;letter-spacing:0}\n.gpl-wrap .gpl-upload{display:flex;align-items:center;justify-content:center;gap:5px;background:#fafbf4;color:#15701e;border:1px dashed #b8e5c6;padding:5px 8px;border-radius:5px;font-family:inherit;font-size:10px;font-weight:600;cursor:pointer;min-height:30px;margin-top:2px;transition:all .12s}\n.gpl-wrap .gpl-upload:hover{background:#e9f3ec;border-color:#15701e;border-style:solid}\n.gpl-wrap .gpl-upload-ico{font-size:13px;line-height:1}\n.gpl-wrap .gpl-upload.is-error{color:#dc2626;border-color:#fca5a5;background:#fef2f2}\n.gpl-wrap .gpl-upload.is-success{color:#15701e;background:#dff5e6;border-color:#15701e;border-style:solid}\n.gpl-wrap .gpl-import-nav{display:flex;align-items:center;gap:6px;margin-top:3px;justify-content:center}\n.gpl-wrap .gpl-imp-btn{width:28px;height:28px;background:#fafbf4;border:1px solid #d9e7dc;border-radius:5px;color:#15701e;cursor:pointer;font-size:14px;font-weight:700;font-family:inherit;line-height:1;padding:0;transition:all .12s}\n.gpl-wrap .gpl-imp-btn:hover{background:#dff5e6;border-color:#15701e}\n.gpl-wrap .gpl-imp-btn:disabled{opacity:0.35;cursor:not-allowed}\n.gpl-wrap .gpl-imp-lbl{font-family:\"DM Mono\",ui-monospace,monospace;font-size:11px;font-weight:600;color:#4c6066;min-width:60px;text-align:center}\n.gpl-wrap .gpl-ab-auto{background:#fafbf4;color:#15701e;border:1px solid #d9e7dc;padding:4px 8px;border-radius:4px;font-family:inherit;font-size:10px;font-weight:600;cursor:pointer;min-height:26px;transition:all .12s;width:100%;margin-top:1px}\n.gpl-wrap .gpl-ab-auto:hover{background:#e9f3ec;border-color:#15701e}\n.gpl-wrap .gpl-ab-auto.is-auto{background:#dff5e6;border-color:#15701e}\n\/* Heading picker \u2014 radio-list style: pick which target to optimise heading for *\/\n.gpl-wrap .gpl-heading-na{background:#f5f6f1;border:1px dashed #d9e7dc;border-radius:5px;padding:8px 10px;font-size:11px;color:#4c6066;line-height:1.5}\n.gpl-wrap .gpl-heading-na strong{color:#145328}\n.gpl-wrap .gpl-heading-auto{background:#fafbf4;border:1px solid #d9e7dc;border-radius:5px;padding:8px 10px}\n.gpl-wrap .gpl-heading-auto-val{font-family:\"DM Mono\",ui-monospace,monospace;font-size:18px;font-weight:700;color:#145328;line-height:1;margin-bottom:3px}\n.gpl-wrap .gpl-heading-auto-lbl{font-size:10.5px;color:#4c6066;line-height:1.4}\n\/* Auto-blocks per-block heading list *\/\n.gpl-wrap .gpl-blocks-list{display:flex;flex-direction:column;gap:4px}\n.gpl-wrap .gpl-blocks-row{display:flex;align-items:center;gap:6px;padding:6px 8px;background:#fafbf4;border:1px solid #d9e7dc;border-radius:5px;font-size:11.5px}\n.gpl-wrap .gpl-blocks-row.is-override{border-color:#15701e;background:#dff5e6}\n.gpl-wrap .gpl-blocks-row .gpl-blocks-lbl{flex:1;min-width:0;font-weight:600;color:#145328}\n.gpl-wrap .gpl-blocks-row .gpl-blocks-area{font-size:10px;color:#4c6066;font-family:\"DM Mono\",ui-monospace,monospace;font-weight:500}\n.gpl-wrap .gpl-blocks-row input[type=\"number\"]{width:48px;font-family:\"DM Mono\",ui-monospace,monospace;font-size:11px;padding:3px 5px;border:1px solid #d9e7dc;border-radius:4px;text-align:right;background:#fff}\n.gpl-wrap .gpl-blocks-row .gpl-blocks-deg-unit{font-size:10px;color:#4c6066;font-family:\"DM Mono\",ui-monospace,monospace}\n.gpl-wrap .gpl-blocks-row button.gpl-blocks-reset-one{background:transparent;border:0;color:#4c6066;cursor:pointer;font-size:13px;padding:2px 4px;line-height:1;border-radius:3px}\n.gpl-wrap .gpl-blocks-row button.gpl-blocks-reset-one:hover{background:#fff;color:#15701e}\n.gpl-wrap .gpl-blocks-row button.gpl-blocks-reset-one[disabled]{opacity:0.3;cursor:default}\n.gpl-wrap .gpl-heading-picker{display:flex;flex-direction:column;gap:4px}\n.gpl-wrap .gpl-heading-intro{font-size:10px;color:#4c6066;font-weight:600;text-transform:uppercase;letter-spacing:.04em;margin:0 0 2px 0}\n.gpl-wrap .gpl-heading-options{display:flex;flex-direction:column;gap:2px}\n.gpl-wrap .gpl-heading-opt{display:flex;align-items:center;gap:8px;background:#fff;color:#212121;border:1px solid #eee;border-radius:5px;padding:6px 9px;font-family:inherit;font-size:11.5px;font-weight:500;cursor:pointer;text-align:left;transition:all .12s;min-height:30px}\n.gpl-wrap .gpl-heading-opt:hover{background:#fafbf4;border-color:#1a7951}\n.gpl-wrap .gpl-heading-opt.is-on{background:#dff5e6;border-color:#15701e;color:#145328;font-weight:600}\n.gpl-wrap .gpl-heading-opt input[type=\"radio\"]{margin:0;accent-color:#15701e;flex:none}\n.gpl-wrap .gpl-heading-opt .gpl-h-lbl{flex:1;min-width:0}\n.gpl-wrap .gpl-heading-opt .gpl-h-val{font-family:\"DM Mono\",ui-monospace,monospace;font-size:11px;font-weight:600;color:#4c6066;white-space:nowrap;padding:2px 6px;background:#fafbf4;border-radius:3px}\n.gpl-wrap .gpl-heading-opt.is-on .gpl-h-val{color:#15701e;background:#fff}\n.gpl-wrap .gpl-heading-opt.is-converge .gpl-h-val::after{content:\" \u2261\";color:#4c6066;font-weight:400}\n\/* Slider visible only in custom mode *\/\n.gpl-wrap .gpl-heading-picker[data-mode=\"custom\"] .gpl-ab-row,\n.gpl-wrap .gpl-heading-picker[data-mode=\"custom\"] .gpl-hint{display:flex}\n.gpl-wrap .gpl-heading-picker .gpl-ab-row,\n.gpl-wrap .gpl-heading-picker .gpl-hint{display:none}\n\/* Warning banner \u2014 own row above the canvas (sibling to playback bar +\n   canvas-shell). Previously it was position:absolute over the canvas, which\n   overlapped the passes-info tray on the left AND got hidden under the\n   Ruler\/Set-AB toolbar on the right. Static-flow placement keeps it clearly\n   visible and doesn't fight for the limited corner real estate. *\/\n.gpl-wrap .gpl-warn{flex:0 0 auto;background:rgba(247,106,12,0.95);color:#fff;padding:7px 14px;font-size:12px;font-weight:600;text-align:center;display:none;border-bottom:1px solid rgba(255,255,255,0.25);line-height:1.35}\n.gpl-wrap .gpl-warn.is-on{display:block}\n\/* Floating CTA \u2014 sits over the canvas bottom-right like field-data-explorer's\n   gpy-floating-cta. Dismiss persists in sessionStorage. Hidden on mobile to\n   avoid covering the map. *\/\n.gpl-wrap .gpl-pro-lock{display:inline-flex;align-items:center;justify-content:center;background:#f76a0c;color:#fff;font-size:9px;font-weight:700;letter-spacing:.08em;padding:2px 5px;border-radius:3px;margin-left:6px;cursor:pointer;font-family:\"DM Mono\",ui-monospace,monospace}\n.gpl-wrap .gpl-pro-lock:hover{background:#ff7d24}\n.gpl-wrap #gpl-approach label.is-pro-locked{opacity:0.6}\n.gpl-wrap #gpl-approach label.is-pro-locked input[type=\"radio\"]{pointer-events:none}\n.gpl-wrap #gpl-approach label.is-pro-locked > span:first-of-type{text-decoration:none}\n.gpl-wrap .gpl-floating-cta{position:absolute;bottom:14px;right:14px;width:240px;max-width:34vw;background:#143a1c;color:#e0eee5;border:1px solid #15701e;border-radius:10px;padding:8px 11px 9px;z-index:6;box-shadow:0 6px 18px rgba(0,0,0,.18);font-family:inherit}\n.gpl-wrap .gpl-floating-cta-h{font-size:10.5px;font-weight:700;color:#9be5b0;text-transform:uppercase;letter-spacing:.08em;margin:0 0 5px 0;font-family:\"DM Mono\",ui-monospace,monospace}\n.gpl-wrap .gpl-floating-cta ul{list-style:none;padding:0;margin:0 0 7px 0;font-size:10.5px;line-height:1.42;color:#e0eee5;font-weight:500}\n.gpl-wrap .gpl-floating-cta li{display:flex;align-items:flex-start;gap:5px;padding:1px 0}\n.gpl-wrap .gpl-floating-cta li::before{content:\"\\2713\";color:#f76a0c;font-weight:700;flex:none;font-size:10px;line-height:1.42}\n.gpl-wrap .gpl-floating-cta-btn{display:flex;align-items:center;justify-content:center;gap:5px;background:#f76a0c;color:#0b0f14;border:0;border-radius:6px;padding:7px 8px;font-weight:700;font-size:12px;text-decoration:none;font-family:inherit;width:100%;transition:background .15s}\n.gpl-wrap .gpl-floating-cta-btn:hover{background:#ff7d24;transform:translateY(-1px)}\n.gpl-wrap .gpl-floating-cta-btn .arr{transition:transform .15s}\n.gpl-wrap .gpl-floating-cta-btn:hover .arr{transform:translateX(3px)}\n\/* Export menu \u2014 drops below the Export toolbar button *\/\n.gpl-wrap .gpl-export-menu{position:absolute;top:54px;right:14px;background:#fff;border:1px solid #d9e7dc;border-radius:7px;box-shadow:0 6px 14px rgba(20,83,40,.10);padding:4px;z-index:7;display:flex;flex-direction:column;gap:2px;min-width:180px}\n.gpl-wrap .gpl-export-menu button{background:transparent;border:0;color:#212121;font-family:inherit;font-size:11.5px;font-weight:500;padding:7px 10px;text-align:left;cursor:pointer;border-radius:5px;transition:background .12s}\n.gpl-wrap .gpl-export-menu button:hover{background:#fafbf4;color:#15701e}\n.gpl-wrap .gpl-export-menu[hidden]{display:none}\n.gpl-wrap .gpl-cta-mini{display:inline-flex;align-items:center;gap:5px;background:#15701e;color:#fff;text-decoration:none;padding:7px 11px;border-radius:6px;font-size:12px;font-weight:600;font-family:inherit;transition:background .15s;min-height:44px;line-height:1}\n.gpl-wrap .gpl-cta-mini:hover{background:#1b7a2a}\n.gpl-wrap .gpl-cta-card{background:linear-gradient(145deg,#eefbf3 0%,#dff5e6 100%);border:1px solid #b8e5c6;border-radius:10px;padding:14px;margin-top:auto;position:relative;overflow:hidden}\n.gpl-wrap .gpl-cta-card::before{content:\"\";position:absolute;top:-30px;right:-30px;width:80px;height:80px;background:radial-gradient(circle,rgba(21,112,30,.14) 0%,transparent 70%);pointer-events:none}\n.gpl-wrap .gpl-cta-card h4{font-size:10px;text-transform:uppercase;letter-spacing:.14em;color:#0f5033;margin:0 0 10px 0;font-weight:700;font-family:\"Poppins\",system-ui,sans-serif;position:relative}\n.gpl-wrap .gpl-cta-list{list-style:none;padding:0;margin:0 0 12px 0;font-size:12px;line-height:1.6;position:relative}\n.gpl-wrap .gpl-cta-list li{color:#1e5d3a;display:flex;align-items:flex-start;gap:8px;font-weight:500;margin-bottom:4px}\n.gpl-wrap .gpl-cta-list li::before{content:\"\u2713\";color:#15701e;font-weight:700;flex:none}\n.gpl-wrap .gpl-cta-btn-lg{display:flex;align-items:center;justify-content:center;gap:6px;background:#15701e;color:#fff;text-decoration:none;padding:11px 12px;border-radius:7px;font-weight:700;font-size:13px;font-family:inherit;position:relative;width:100%;transition:background .15s;min-height:44px;box-sizing:border-box}\n.gpl-wrap .gpl-cta-btn-lg:hover{background:#1b7a2a}\n.gpl-wrap .gpl-cta-arr{transition:transform .15s}\n.gpl-wrap .gpl-cta-btn-lg:hover .gpl-cta-arr{transform:translateX(3px)}\n.gpl-wrap .gpl-cta-note{font-size:10px;color:#4c6066;margin:8px 0 0 0;line-height:1.45;text-align:center;font-style:italic}\n.gpl-wrap .gpl-tray{position:absolute;top:10px;left:10px;background:rgba(255,255,255,.95);border:1px solid #eeeeee;border-radius:8px;padding:6px 10px;font-family:\"DM Mono\",ui-monospace,monospace;font-size:11px;color:#4c6066;backdrop-filter:blur(4px);box-shadow:0 2px 6px rgba(0,0,0,.05);z-index:2}\n\/* Playback bar sits as a dedicated row ABOVE the canvas (sibling to .gpl-canvas-shell\n   inside the .gpl-main flex column). This frees the top-of-canvas for the map toolbar\n   (.gpl-tools) so they never overlap \u2014 previously both were absolute-positioned at\n   top:10\/14 px and collided on the right side. *\/\n.gpl-wrap .gpl-pb{flex:0 0 auto;background:#ffffff;border:0;border-bottom:1px solid #e8ebe2;border-radius:0;padding:8px 12px;display:flex;align-items:center;gap:10px;box-shadow:0 1px 3px rgba(20,83,40,0.04);flex-wrap:wrap;z-index:auto}\n.gpl-wrap .gpl-pb-btn{background:#15701e;color:#fff;border:0;width:32px;height:32px;border-radius:6px;cursor:pointer;font-size:13px;display:flex;align-items:center;justify-content:center;padding:0;font-family:inherit;transition:background .15s;flex:none;line-height:1}\n.gpl-wrap .gpl-pb-btn:hover{background:#1b7a2a}\n.gpl-wrap .gpl-pb-btn.is-playing::before{content:\"\u275a\u275a\";font-size:11px;letter-spacing:-1px}\n.gpl-wrap .gpl-pb-btn.is-playing{font-size:0}\n.gpl-wrap .gpl-pb-track{flex:1;height:8px;background:#fafbf4;border-radius:4px;position:relative;cursor:pointer;min-width:80px;border:1px solid #eeeeee}\n.gpl-wrap .gpl-pb-fill{position:absolute;left:0;top:0;height:100%;background:#15701e;border-radius:3px;pointer-events:none;width:0%}\n.gpl-wrap .gpl-pb-thumb{position:absolute;top:50%;left:0%;transform:translate(-50%,-50%);width:14px;height:14px;background:#15701e;border:2px solid #ffffff;border-radius:50%;pointer-events:none;box-shadow:0 1px 4px rgba(0,0,0,.25)}\n.gpl-wrap .gpl-pb-seg{flex:none;display:flex;gap:2px;padding:2px;background:#fafbf4;border:1px solid #eeeeee;border-radius:6px}\n.gpl-wrap .gpl-pb-spd{padding:4px 7px;font-size:10px;background:transparent;border:0;color:#4c6066;cursor:pointer;border-radius:4px;font-family:\"DM Mono\",ui-monospace,monospace;font-weight:700;min-width:28px;font-family:inherit}\n.gpl-wrap .gpl-pb-spd.on{background:#15701e;color:#fff}\n.gpl-wrap .gpl-pb-stats{flex:none;font-family:\"DM Mono\",ui-monospace,monospace;font-size:10px;color:#4c6066;text-align:right;min-width:130px;line-height:1.3;font-weight:600}\n.gpl-wrap .gpl-scale{position:absolute;right:10px;bottom:10px;background:rgba(255,255,255,.95);border:1px solid #eeeeee;border-radius:6px;padding:4px 8px;font-family:\"DM Mono\",ui-monospace,monospace;font-size:10px;font-weight:600;color:#4c6066;display:flex;align-items:center;gap:6px;backdrop-filter:blur(4px);z-index:3;line-height:1;box-shadow:0 2px 4px rgba(0,0,0,.05)}\n.gpl-wrap .gpl-scale-bar{display:inline-block;width:60px;height:6px;border-left:2px solid #145328;border-right:2px solid #145328;border-bottom:2px solid #145328;box-sizing:border-box}\n.gpl-wrap .gpl-scale-lbl{white-space:nowrap}\n\/* Rule \u00a79 \u2014 map toolbar mirrors field-explorer's .gpy-ctrl: horizontal flex\n   row at top-right of the canvas, 34\u00d734 px buttons with subtle white background,\n   ruler \/ set-AB \/ clear are \"wide\" variants with icon + short text label, active\n   state uses the brand accent orange (#f76a0c). Keep this exact look on every\n   tool that ships a map so user muscle-memory is consistent. *\/\n.gpl-wrap .gpl-tools{position:absolute;top:14px;right:14px;display:flex;gap:6px;z-index:5}\n.gpl-wrap .gpl-tool-btn{background:#ffffff;border:1px solid #eeeeee;color:#4c6066;width:34px;height:34px;border-radius:7px;cursor:pointer;font-size:16px;font-weight:600;padding:0;font-family:inherit;line-height:1;box-shadow:0 1px 3px rgba(20,83,40,0.06);transition:all .15s;display:inline-flex;align-items:center;justify-content:center;gap:5px}\n.gpl-wrap .gpl-tool-btn svg{width:16px;height:16px;flex:none;display:block}\n.gpl-wrap .gpl-tool-btn:hover{background:#fafbf4;border-color:#d9e7dc;color:#15701e}\n.gpl-wrap .gpl-tool-btn.is-on{background:#f76a0c;color:#fff;border-color:#f76a0c}\n.gpl-wrap .gpl-tool-btn.is-on:hover{background:#ff7d24;border-color:#ff7d24;color:#fff}\n.gpl-wrap .gpl-tool-btn.gpl-btn-wide{width:auto;padding:0 10px;font-size:11px;letter-spacing:.02em}\n.gpl-wrap .gpl-ruler-hint{position:absolute;left:50%;bottom:14px;transform:translateX(-50%);background:rgba(20,83,40,0.92);color:#fff;padding:7px 12px;border-radius:7px;font-size:11px;font-weight:600;font-family:\"DM Mono\",ui-monospace,monospace;display:none;z-index:3;box-shadow:0 2px 8px rgba(0,0,0,.15)}\n.gpl-wrap .gpl-ruler-hint.is-on{display:block}\n.gpl-wrap .gpl-legend{position:absolute;bottom:10px;left:10px;background:rgba(255,255,255,.95);border:1px solid #eeeeee;border-radius:8px;padding:8px 12px;font-size:11px;display:flex;flex-direction:column;gap:4px;backdrop-filter:blur(4px)}\n.gpl-wrap .gpl-lg-row{display:flex;align-items:center;gap:6px}\n.gpl-wrap .gpl-lg-swatch{width:14px;height:3px;border-radius:2px;flex:none}\n\/* Recompute loader \u2014 overlay inside the canvas-shell. Shown when a\n   recompute is in flight; hides as soon as the synchronous geometry pass\n   completes. requestAnimationFrame + setTimeout(0) in scheduleRecompute\n   yield one frame before the heavy work so the browser actually paints\n   this overlay before the main thread freezes. *\/\n.gpl-wrap .gpl-loader{position:absolute;top:0;left:0;right:0;bottom:0;display:none;align-items:center;justify-content:center;background:rgba(250,251,244,0.66);backdrop-filter:blur(2px);z-index:30;pointer-events:none}\n.gpl-wrap .gpl-loader.is-on{display:flex}\n.gpl-wrap .gpl-loader-card{display:flex;align-items:center;gap:13px;background:#fff;padding:14px 20px;border-radius:12px;box-shadow:0 10px 28px rgba(20,83,40,0.22);font-family:var(--wp--preset--font-family--nunito,\"Nunito\",sans-serif)}\n\/* GeoPard-branded \"G\" badge with an orbiting accent ring (the brand\n   orange) so the user clearly reads \"GeoPard is calculating your field\". *\/\n.gpl-wrap .gpl-loader-badge{position:relative;width:40px;height:40px;flex:none;display:flex;align-items:center;justify-content:center}\n.gpl-wrap .gpl-loader-badge::before{content:\"\";position:absolute;inset:0;border-radius:50%;border:2.5px solid #e3ece1;border-top-color:#f76a0c;border-right-color:#f76a0c;animation:gpl-spin .7s linear infinite}\n.gpl-wrap .gpl-loader-g{width:30px;height:30px;border-radius:8px;background:linear-gradient(135deg,#145328 0%,#1a7951 100%);display:flex;align-items:center;justify-content:center;color:#fff;font-weight:700;font-size:15px;animation:gpl-loader-pulse 1.4s ease-in-out infinite}\n.gpl-wrap .gpl-loader-txt{display:flex;flex-direction:column;gap:2px;line-height:1.25}\n.gpl-wrap .gpl-loader-title{font-size:13px;font-weight:700;color:#145328;letter-spacing:.01em}\n.gpl-wrap .gpl-loader-sub{font-size:11px;font-weight:600;color:#4c6066}\n@keyframes gpl-spin{to{transform:rotate(360deg)}}\n@keyframes gpl-loader-pulse{0%,100%{transform:scale(1)}50%{transform:scale(0.9)}}\n\n@media (max-width: 820px){\n  \/* Phone\/tablet: canvas full-width on top, then SETTINGS (left, starts\n     with the Approach picker) immediately under the canvas so the user\n     can switch approach without scrolling past Analytics, then ANALYTICS\n     (right) below that. min-height:auto lets each grid row size itself;\n     canvas row uses 60vh on portrait phones so it dominates the viewport. *\/\n  .gpl-wrap .gpl-grid{grid-template-columns:1fr;grid-template-areas:\"main\" \"left\" \"right\";grid-template-rows:60vh auto auto;min-height:auto;max-height:none}\n  .gpl-wrap .gpl-below{grid-template-columns:1fr;padding:12px}\n  \/* On narrow screens the info-icon tooltip would otherwise extend\n     230 px LEFT and overflow the viewport. Drop it back down to a\n     fixed-position style: tooltip sits below the icon, full row width\n     minus padding, no shifting needed. *\/\n  .gpl-wrap .gpl-info-ico::after{width:auto;left:8px;right:8px;top:calc(100% + 8px);position:absolute;max-width:none}\n  .gpl-wrap .gpl-info-ico::before{display:none}\n  .gpl-wrap .gpl-side{border-right:0;border-top:1px solid #eeeeee;max-height:none;padding:12px 14px;gap:10px}\n  .gpl-wrap .gpl-splitter{display:none}\n  .gpl-wrap .gpl-splitter-right{display:none}\n  .gpl-wrap .gpl-side-right{border-left:0;border-top:1px solid #eeeeee;max-height:none;padding:12px 14px;gap:10px}\n  .gpl-wrap .gpl-main{min-height:50vh}\n  \/* Bigger cards + tap targets on touch *\/\n  .gpl-wrap .gpl-card h3{font-size:10px}\n  .gpl-wrap .gpl-approach label{min-height:44px;font-size:13px;padding:7px 10px}\n  .gpl-wrap input[type=number],.gpl-wrap select{font-size:14px;min-height:38px}\n  .gpl-wrap .gpl-fld{min-height:44px;font-size:11px;padding:6px 8px}\n  .gpl-wrap .gpl-fld-sub{font-size:9px}\n  .gpl-wrap .gpl-upload{min-height:44px;font-size:12px}\n  .gpl-wrap .gpl-top-fields .gpl-upload{min-height:44px;font-size:12px;padding:9px 16px}\n  .gpl-wrap .gpl-ab-auto{min-height:36px;font-size:11px}\n  \/* Tool buttons: smaller (icon-only) on phone\/tablet so all 7 fit in one\n     row right under the playback bar. Wide buttons (Ruler \/ Set AB \/\n     Clear \/ Export) collapse their text label to icon-only on mobile;\n     the icon + title attribute remain for affordance. *\/\n  .gpl-wrap .gpl-tool-btn{width:32px;height:32px}\n  .gpl-wrap .gpl-tool-btn svg{width:16px;height:16px}\n  .gpl-wrap .gpl-tool-btn.gpl-btn-wide{width:32px;padding:0;font-size:0;letter-spacing:0}\n  .gpl-wrap .gpl-tool-btn.gpl-btn-wide > span{display:none}\n  .gpl-wrap .gpl-cta-mini,.gpl-wrap .gpl-cta-btn-lg{min-height:44px}\n  .gpl-wrap .gpl-slider-val{font-size:11px;min-width:80px}\n  .gpl-wrap .gpl-range{min-height:32px}\n  \/* Results row font + spacing \u2014 easier to read on phones *\/\n  .gpl-wrap .gpl-results{font-size:12px;padding:8px 10px;gap:3px}\n  .gpl-wrap .gpl-r-k{font-size:11px}\n  .gpl-wrap .gpl-r-v{font-size:13px}\n  \/* Compare All grid \u2014 keep 4 columns but tighter *\/\n  .gpl-wrap .gpl-cmp-row{padding:8px 10px;font-size:11.5px;grid-template-columns:minmax(140px,1.5fr) repeat(10, minmax(68px, 1fr));min-width:880px}\n  .gpl-wrap .gpl-cmp{overflow-x:auto}\n  \/* Playback strip \u2014 wrap better when narrow *\/\n  .gpl-wrap .gpl-pb{flex-wrap:wrap;padding:6px 8px;gap:6px}\n  .gpl-wrap .gpl-pb-stats{min-width:0;flex-basis:100%;text-align:left;font-size:10px;line-height:1.35;padding-top:2px;border-top:1px dashed #eeeeee}\n  .gpl-wrap .gpl-pb-track{min-width:50px}\n  \/* Tools \u2014 sit right under the playback bar at the top of the canvas.\n     The playback bar is a sibling ABOVE the canvas-shell, so top:8px is\n     directly under the playback line. *\/\n  .gpl-wrap .gpl-tools{top:8px;right:8px;gap:4px}\n  .gpl-wrap .gpl-export-menu{top:46px;right:8px}\n  .gpl-wrap .gpl-tray{top:48px;font-size:10px;padding:5px 8px}\n  \/* Legend and scale-bar \u2014 smaller, less in the way on phones *\/\n  .gpl-wrap .gpl-legend{font-size:10px;padding:7px 10px;gap:3px}\n  .gpl-wrap .gpl-scale{font-size:9px;padding:3px 7px}\n  \/* Unit-toggle (km\/mi) tabs \u2014 bigger tap target *\/\n  .gpl-wrap .gpl-unit-tab{padding:5px 12px;font-size:11px;min-height:32px}\n  \/* Field-picker grid \u2014 2\u00d72 still, slightly more breathing room *\/\n  .gpl-wrap .gpl-field-picker{gap:5px}\n}\n@media (max-width: 480px){\n  .gpl-wrap .gpl-main{min-height:48vh}\n  .gpl-wrap .gpl-grid{grid-template-rows:48vh auto auto}\n  .gpl-wrap .gpl-approach label{min-height:44px;font-size:14px}\n  .gpl-wrap input[type=number],.gpl-wrap select{font-size:14px;min-height:40px}\n  .gpl-wrap .gpl-cta-mini,.gpl-wrap .gpl-cta-btn-lg{min-height:44px}\n  .gpl-wrap .gpl-fld{min-height:46px;font-size:12px}\n  .gpl-wrap .gpl-tool-btn{width:34px;height:34px}\n  .gpl-wrap .gpl-tool-btn svg{width:17px;height:17px}\n  .gpl-wrap .gpl-tool-btn.gpl-btn-wide{width:34px}\n  .gpl-wrap .gpl-cmp-row{grid-template-columns:1fr 34px 42px 42px;font-size:10.5px}\n  .gpl-wrap .gpl-input-row .gpl-unit{font-size:11px;width:30px}\n  \/* Top bar (header + CTA) \u2014 let the CTA wrap onto its own row on small screens *\/\n  .gpl-wrap .gpl-top{padding:10px 12px;gap:8px}\n  .gpl-wrap .gpl-cta{padding:10px 14px;font-size:13px;min-height:42px}\n  .gpl-wrap .gpl-h1{font-size:14px}\n  \/* Floating CTA: hide on small screens \u2014 covers the map *\/\n  .gpl-wrap .gpl-floating-cta{display:none}\n}\n<\/style>\n\n<div class=\"gpl-top\">\n  <div class=\"gpl-brand\">\n    <div class=\"gpl-logo\">G<\/div>\n    <div>\n      <div class=\"gpl-h1\">Guidance Lines Simulator<\/div>\n    <\/div>\n  <\/div>\n  <div class=\"gpl-top-fields\" role=\"region\" aria-label=\"Field picker\">\n    <span class=\"gpl-top-fields-lbl\">Field:<\/span>\n    <div class=\"gpl-field-picker\" id=\"gpl-field-picker\">\n      <button type=\"button\" class=\"gpl-fld is-on\" data-field=\"lshape\" title=\"L-shape with obstacle \/ hill \u00b7 25 ha \u00b7 Auto-blocks wins here\">L-shape<\/button>\n      <button type=\"button\" class=\"gpl-fld\" data-field=\"hex\" title=\"Irregular hex \u00b7 27 ha\">Hex<\/button>\n      <button type=\"button\" class=\"gpl-fld\" data-field=\"rect\" title=\"Classic Midwest rectangle \u00b7 31 ha\">Rect<\/button>\n      <button type=\"button\" class=\"gpl-fld\" data-field=\"pivot\" title=\"Irrigation pivot circle \u00b7 28 ha\">Pivot<\/button>\n      <button type=\"button\" class=\"gpl-fld\" data-field=\"twoblock\" title=\"Two elongated disconnected blocks with near-perpendicular dominant axes \u00b7 25 ha \u00b7 Auto-blocks wins here clearly\">2 blocks<\/button>\n      <button type=\"button\" class=\"gpl-fld gpl-fld-imported\" data-field=\"imported\" id=\"gpl-fld-imported\" title=\"Field derived from your imported lines \/ boundary\" hidden>Imported<\/button>\n    <\/div>\n    <label class=\"gpl-upload\" id=\"gpl-upload-lbl\" title=\"Run the simulator on YOUR field \u2014 free, in your browser, no signup. Drop a field boundary or your existing guidance lines (GeoJSON \/ KML \/ Shapefile zip). Your data never leaves the browser.\">\n      <input type=\"file\" id=\"gpl-upload\" accept=\".geojson,.json,.kml,.zip,.shp,application\/json,application\/zip,application\/vnd.google-earth.kml+xml\" multiple hidden>\n      <span class=\"gpl-upload-ico\">\u2934<\/span>\n      <span class=\"gpl-upload-txt\">Run on your field \u2014 free, no signup<\/span>\n    <\/label>\n    <span class=\"gpl-upload-hint\" id=\"gpl-upload-hint\" hidden>Drop guidance lines or field boundary \u00b7 auto-derive boundary<\/span>\n  <\/div>\n<\/div>\n\n<div class=\"gpl-grid\">\n  <aside class=\"gpl-side\">\n    <div class=\"gpl-card\">\n      <h3>Approach<\/h3>\n      <div class=\"gpl-approach\" id=\"gpl-approach\">\n        <label id=\"gpl-ap-uploaded-label\" class=\"gpl-ap-uploaded\" hidden><input type=\"radio\" name=\"gpl-ap\" value=\"uploaded\"><span>Your current lines<\/span><span class=\"gpl-reco\" data-reco=\"uploaded\">Best<\/span><span class=\"gpl-info-ico\" data-tip=\"Play back + analyse the guidance lines you uploaded. Shown only after you import existing lines (GeoJSON \/ KML \/ Shapefile). Compares head-to-head with the four proposed approaches in the same units.\">i<\/span><\/label>\n        <label class=\"is-on\"><input type=\"radio\" name=\"gpl-ap\" value=\"ab-straight\" checked><span>AB Straight<\/span><span class=\"gpl-reco\" data-reco=\"ab-straight\">Best<\/span><span class=\"gpl-info-ico\" data-tip=\"Parallel straight passes from an AB reference line. Standard for flat, rectangular fields with uniform soil and terrain. Lowest path complexity, easiest for autosteer and the operator to execute.\">i<\/span><\/label>\n        <label><input type=\"radio\" name=\"gpl-ap\" value=\"ab-curve\"><span>AB Curve<\/span><span class=\"gpl-reco\" data-reco=\"ab-curve\">Best<\/span><span class=\"gpl-info-ico\" data-tip=\"Curved reference baseline. Every pass is a parallel offset of one recorded curve, so passes tile at the full implement width \u2014 use it when terraces, ditches, or dry creek beds suggest a natural curve. Slightly more overlap than AB Straight near irregular boundaries (the curve meets the edge at varying angles). Numbers are planimetric (2D); on hilly fields the true 3D surface area is ~1\u20132 % larger.\">i<\/span><\/label>\n        <label><input type=\"radio\" name=\"gpl-ap\" value=\"boundary\"><span>Boundary Follow<\/span><span class=\"gpl-reco\" data-reco=\"boundary\">Best<\/span><span class=\"gpl-info-ico\" data-tip=\"Passes follow the field boundary inward in concentric loops. Best for irregular field shapes where straight passes leave many gaps. Headland and body merge into one continuous spiral, but turns can be tighter than the equipment supports on sharp corners.\">i<\/span><\/label>\n        <label id=\"gpl-ap-adaptive-label\"><input type=\"radio\" name=\"gpl-ap\" value=\"adaptive\"><span>Topography follow<\/span><span class=\"gpl-reco\" data-reco=\"adaptive\">Best<\/span><span class=\"gpl-pro-lock\" id=\"gpl-ap-adaptive-lock\" hidden title=\"Requires real elevation data. Sign up at GeoPard to use Topography follow on your own fields.\">PRO<\/span><span class=\"gpl-info-ico\" data-tip=\"Passes follow elevation contours (lines of constant height). Reduces cross-slope driving on hilly fields: less erosion, less runoff, ~10 to 25% less fuel on slopes above 5%. Pairs naturally with strip-cropping and cover-crop systems.\">i<\/span><\/label>\n        <label><input type=\"radio\" name=\"gpl-ap\" value=\"auto-blocks\"><span>Auto-blocks<\/span><span class=\"gpl-reco\" data-reco=\"auto-blocks\">Best<\/span><span class=\"gpl-info-ico\" data-tip=\"Splits the field into natural sub-blocks (multi-part shapefiles, L-shapes, complex concave boundaries) and picks the optimal AB heading per block. Each region is worked with its own pass orientation aligned to that block's longest edge. Best for irregular fields where one heading can't cover the whole area efficiently.\">i<\/span><\/label>\n      <\/div>\n    <\/div>\n    <div class=\"gpl-card\" id=\"gpl-heading-card\">\n      <h3>AB line direction<\/h3>\n      <!-- Boundary Follow message \u2014 heading is irrelevant for a spiral pattern. -->\n      <div class=\"gpl-heading-na\" id=\"gpl-heading-na\" hidden>\n        <strong>Not used.<\/strong> Boundary Follow spirals from the field edge inward \u2014 there&#8217;s no AB heading to set.\n      <\/div>\n      <!-- Topography Follow read-only \u2014 algorithm picks the angle perpendicular to gradient. -->\n      <div class=\"gpl-heading-auto\" id=\"gpl-heading-auto\" hidden>\n        <div class=\"gpl-heading-auto-val\"><span id=\"gpl-heading-auto-deg\">\u2014\u00b0<\/span><\/div>\n        <div class=\"gpl-heading-auto-lbl\">Auto \u00b7 oriented along the field contours<\/div>\n      <\/div>\n      <!-- Auto-blocks \u2014 list of detected sub-blocks with per-block angle input. -->\n      <div class=\"gpl-blocks-list\" id=\"gpl-blocks-list\" hidden>\n        <div class=\"gpl-heading-intro\">Per-block heading <span style=\"font-weight:400;color:#4c6066;text-transform:none;letter-spacing:0\">(\u00b0)<\/span><\/div>\n        <div id=\"gpl-blocks-rows\"><\/div>\n        <button type=\"button\" id=\"gpl-blocks-reset\" class=\"gpl-ab-auto\" title=\"Reset every block back to its automatic orientation\">\u21ba Reset all blocks<\/button>\n      <\/div>\n      <div class=\"gpl-heading-picker\" id=\"gpl-heading-picker\">\n        <div class=\"gpl-heading-intro\">Optimise for:<\/div>\n        <div class=\"gpl-heading-options\" id=\"gpl-heading-options\">\n          <label class=\"gpl-heading-opt is-on\" data-strategy=\"longest\" title=\"Align passes with the field's longest edge. Geometric default \u2014 smoothest, easiest to drive.\">\n            <input type=\"radio\" name=\"gpl-heading\" value=\"longest\" checked>\n            <span class=\"gpl-h-lbl\">Longest Edge<\/span>\n            <span class=\"gpl-h-val\" id=\"gpl-h-longest\">\u2014<\/span>\n          <\/label>\n          <label class=\"gpl-heading-opt\" data-strategy=\"crossings\" title=\"Angle that minimises path self-crossings (rule \u00a76). Fewer crossings = less wheel-track repeats, cleaner plan.\">\n            <input type=\"radio\" name=\"gpl-heading\" value=\"crossings\">\n            <span class=\"gpl-h-lbl\">Fewest Crossings<\/span>\n            <span class=\"gpl-h-val\" id=\"gpl-h-crossings\">\u2014<\/span>\n          <\/label>\n          <label class=\"gpl-heading-opt\" data-strategy=\"distance\" title=\"Angle that minimises total drive distance (pass km + turn km). Best diesel outcome on most fields.\">\n            <input type=\"radio\" name=\"gpl-heading\" value=\"distance\">\n            <span class=\"gpl-h-lbl\">Shortest Distance<\/span>\n            <span class=\"gpl-h-val\" id=\"gpl-h-distance\">\u2014<\/span>\n          <\/label>\n          <label class=\"gpl-heading-opt\" data-strategy=\"time\" title=\"Angle that minimises total work TIME. Turn-arounds are driven slower than working passes, so the time-optimal heading can differ from shortest-distance \u2014 it trades a little distance for fewer\/shorter turns.\">\n            <input type=\"radio\" name=\"gpl-heading\" value=\"time\">\n            <span class=\"gpl-h-lbl\">Shortest Time<\/span>\n            <span class=\"gpl-h-val\" id=\"gpl-h-time\">\u2014<\/span>\n          <\/label>\n          <label class=\"gpl-heading-opt\" data-strategy=\"custom\" title=\"Dial in a custom heading angle \u2014 useful when terrain, tramlines, or operator preference overrides the algorithm.\">\n            <input type=\"radio\" name=\"gpl-heading\" value=\"custom\">\n            <span class=\"gpl-h-lbl\">Custom<\/span>\n            <span class=\"gpl-h-val\" id=\"gpl-h-custom\">slider<\/span>\n          <\/label>\n        <\/div>\n        <div class=\"gpl-slider-row gpl-ab-row\">\n          <input type=\"range\" id=\"gpl-ab-deg\" min=\"0\" max=\"180\" step=\"1\" value=\"0\" class=\"gpl-range\">\n          <button type=\"button\" id=\"gpl-ab-auto\" class=\"gpl-ab-auto\" title=\"Reset to Longest Edge\">\u21ba<\/button>\n        <\/div>\n        <div class=\"gpl-hint\" id=\"gpl-ab-val\">0\u00b0<\/div>\n        <!-- AB direction by GPS coordinates. Useful when the operator\n             knows the exact A and B GPS points the tractor logged\n             (from a previous season's pass) and wants to lock the\n             simulator's axis to that recorded line. -->\n        <div class=\"gpl-ab-coords\" id=\"gpl-ab-coords\">\n          <div class=\"gpl-ab-coords-title\">Or by GPS coordinates<\/div>\n          <div class=\"gpl-ab-coords-row\">\n            <span class=\"gpl-ab-coords-lbl\">A<\/span>\n            <input type=\"number\" id=\"gpl-ab-a-lat\" placeholder=\"latitude\" step=\"0.000001\" inputmode=\"decimal\">\n            <input type=\"number\" id=\"gpl-ab-a-lng\" placeholder=\"longitude\" step=\"0.000001\" inputmode=\"decimal\">\n          <\/div>\n          <div class=\"gpl-ab-coords-row\">\n            <span class=\"gpl-ab-coords-lbl\">B<\/span>\n            <input type=\"number\" id=\"gpl-ab-b-lat\" placeholder=\"latitude\" step=\"0.000001\" inputmode=\"decimal\">\n            <input type=\"number\" id=\"gpl-ab-b-lng\" placeholder=\"longitude\" step=\"0.000001\" inputmode=\"decimal\">\n          <\/div>\n          <button type=\"button\" id=\"gpl-ab-coords-apply\" class=\"gpl-ab-coords-btn\" title=\"Use the bearing between A and B as the AB direction\">Apply<\/button>\n          <div class=\"gpl-ab-coords-hint\" id=\"gpl-ab-coords-hint\"><\/div>\n        <\/div>\n      <\/div>\n    <\/div>\n    <div class=\"gpl-card\">\n      <h3>Equipment<\/h3>\n      <div class=\"gpl-select-row gpl-select-row-inline\">\n        <label for=\"gpl-machine\">Machine<\/label>\n        <select id=\"gpl-machine\">\n          <option value=\"tractor-std\" selected>Tractor + implement (std)<\/option>\n          <option value=\"tractor-large\">Large tractor + planter<\/option>\n          <option value=\"sprayer\">Self-propelled sprayer<\/option>\n          <option value=\"combine\">Combine harvester<\/option>\n          <option value=\"articulated\">Articulated 4WD<\/option>\n          <option value=\"custom\">Custom (manual radius)<\/option>\n        <\/select>\n      <\/div>\n      <div class=\"gpl-input-row\">\n        <label for=\"gpl-wm\">Width<\/label>\n        <input type=\"number\" id=\"gpl-wm\" min=\"3\" max=\"60\" step=\"0.5\" value=\"18\">\n        <span class=\"gpl-unit\" data-u=\"width\">m<\/span>\n      <\/div>\n    <\/div>\n    <div class=\"gpl-card\">\n      <h3>Headland<\/h3>\n      <div class=\"gpl-slider-row\">\n        <input type=\"range\" id=\"gpl-hl-mult\" min=\"0\" max=\"4\" step=\"1\" value=\"1\" class=\"gpl-range\">\n        <span class=\"gpl-slider-val\" id=\"gpl-hl-val\">18 m \u00b7 1\u00d7<\/span>\n      <\/div>\n    <\/div>\n    <div class=\"gpl-card\">\n      <h3>Turnarounds<\/h3>\n      <div class=\"gpl-select-row gpl-select-row-inline\">\n        <label for=\"gpl-turn-style\">Style<\/label>\n        <select id=\"gpl-turn-style\">\n          <option value=\"uturn\" selected>U-turn (half-circle)<\/option>\n          <option value=\"none\">None \u2014 driver decides<\/option>\n        <\/select>\n      <\/div>\n      <div class=\"gpl-input-row\">\n        <label for=\"gpl-turn-r\">Turn r<\/label>\n        <input type=\"number\" id=\"gpl-turn-r\" min=\"4\" max=\"30\" step=\"0.5\" value=\"6\">\n        <span class=\"gpl-unit\" data-u=\"turn-r\">m<\/span>\n      <\/div>\n      <div class=\"gpl-hint\" id=\"gpl-turn-r-hint\">auto \u00b7 std tractor<\/div>\n    <\/div>\n  <\/aside>\n  <div class=\"gpl-splitter\" id=\"gpl-splitter\" aria-label=\"Resize settings panel\" title=\"Drag to resize\"><\/div>\n\n  <main class=\"gpl-main\">\n    <div class=\"gpl-pb\" id=\"gpl-pb\">\n      <button type=\"button\" class=\"gpl-pb-btn\" id=\"gpl-pb-play\" aria-label=\"Play \/ pause\">\u25b6<\/button>\n      <div class=\"gpl-pb-track\" id=\"gpl-pb-track\">\n        <div class=\"gpl-pb-fill\" id=\"gpl-pb-fill\"><\/div>\n        <div class=\"gpl-pb-thumb\" id=\"gpl-pb-thumb\"><\/div>\n      <\/div>\n      <div class=\"gpl-pb-seg\">\n        <button type=\"button\" class=\"gpl-pb-spd on\" data-spd=\"1\">1\u00d7<\/button>\n        <button type=\"button\" class=\"gpl-pb-spd\" data-spd=\"2\">2\u00d7<\/button>\n        <button type=\"button\" class=\"gpl-pb-spd\" data-spd=\"4\">4\u00d7<\/button>\n      <\/div>\n      <div class=\"gpl-pb-stats\" id=\"gpl-pb-stats\">0% \u00b7 0 \/ 0 km<\/div>\n    <\/div>\n    <div class=\"gpl-warn\" id=\"gpl-warn\"><\/div>\n    <div class=\"gpl-canvas-shell\">\n    <canvas class=\"gpl-canvas\" id=\"gpl-canvas\"><\/canvas>\n    <div class=\"gpl-loader\" id=\"gpl-loader\" aria-live=\"polite\" aria-hidden=\"true\">\n      <div class=\"gpl-loader-card\">\n        <div class=\"gpl-loader-badge\"><span class=\"gpl-loader-g\">G<\/span><\/div>\n        <div class=\"gpl-loader-txt\">\n          <span class=\"gpl-loader-title\">Calculating your field<\/span>\n          <span class=\"gpl-loader-sub\" id=\"gpl-loader-sub\">Planning guidance lines\u2026<\/span>\n        <\/div>\n      <\/div>\n    <\/div>\n    <div class=\"gpl-copyright\">\u00a9 GeoPard Agriculture<\/div>\n    <div class=\"gpl-tray\" id=\"gpl-tray\">\u2014 ha sample<\/div>\n    <div class=\"gpl-legend\">\n      <div class=\"gpl-lg-row\"><span class=\"gpl-lg-swatch\" style=\"background:linear-gradient(to right,#6d4fa2 0%,#4ec0a7 25%,#ffe882 50%,#f5a85c 75%,#db5050 100%);width:32px;height:8px;border-radius:2px\"><\/span>Elevation low \u2192 high<\/div>\n      <div class=\"gpl-lg-row\"><span class=\"gpl-lg-swatch\" style=\"background:#145328\"><\/span>Field boundary<\/div>\n      <div class=\"gpl-lg-row\"><span class=\"gpl-lg-swatch\" style=\"background:#f76a0c\"><\/span>Guidance pass<\/div>\n      <div class=\"gpl-lg-row\"><span class=\"gpl-lg-swatch\" style=\"background:rgba(26,121,81,.35)\"><\/span>Headland strip<\/div>\n      <div class=\"gpl-lg-row\"><span class=\"gpl-lg-swatch\" style=\"background:#a21caf\"><\/span>U-turn \/ direction \u2192<\/div>\n      <div class=\"gpl-lg-row\"><span class=\"gpl-lg-swatch\" style=\"background:rgba(162,28,175,.14);width:14px;height:8px\"><\/span>Wheel-track compaction<\/div>\n      <div class=\"gpl-lg-row\"><span class=\"gpl-lg-swatch\" style=\"background:rgba(247,106,12,.35);width:14px;height:8px\"><\/span>Swath covered<\/div>\n      <div class=\"gpl-lg-row\" id=\"gpl-lg-uploaded\" hidden><span class=\"gpl-lg-swatch\" style=\"background:rgba(33,102,200,.55);width:14px;height:0;border-top:2px dashed rgba(33,102,200,.55)\"><\/span>Your existing lines<\/div>\n      <div class=\"gpl-lg-row\" id=\"gpl-lg-uploaded-ring\" hidden><span class=\"gpl-lg-swatch\" style=\"background:rgba(33,102,200,.85);width:14px;height:0;border-top:2px solid rgba(33,102,200,.85)\"><\/span>Your headland trace<\/div>\n    <\/div>\n    <div class=\"gpl-scale\" id=\"gpl-scale\"><span class=\"gpl-scale-bar\"><\/span><span class=\"gpl-scale-lbl\" id=\"gpl-scale-lbl\">\u2014 m<\/span><\/div>\n    <div class=\"gpl-tools\">\n      <button type=\"button\" class=\"gpl-tool-btn gpl-btn-wide\" id=\"gpl-tool-ruler\" title=\"Measure distance \u2014 click two points on the map, click again to clear\" aria-label=\"Ruler\">\n        <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"2\" y=\"9\" width=\"20\" height=\"6\" rx=\"1\"\/><line x1=\"6\" y1=\"9\" x2=\"6\" y2=\"13\"\/><line x1=\"10\" y1=\"9\" x2=\"10\" y2=\"13\"\/><line x1=\"14\" y1=\"9\" x2=\"14\" y2=\"13\"\/><line x1=\"18\" y1=\"9\" x2=\"18\" y2=\"13\"\/><\/svg>\n        <span>Ruler<\/span>\n      <\/button>\n      <button type=\"button\" class=\"gpl-tool-btn gpl-btn-wide\" id=\"gpl-tool-ab\" title=\"Set AB line \u2014 click two points to lock the guidance direction; ESC to cancel\" aria-label=\"Set AB line\">\n        <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"5\" cy=\"19\" r=\"2\"\/><circle cx=\"19\" cy=\"5\" r=\"2\"\/><line x1=\"6.4\" y1=\"17.6\" x2=\"17.6\" y2=\"6.4\"\/><\/svg>\n        <span>Set AB<\/span>\n      <\/button>\n      <button type=\"button\" class=\"gpl-tool-btn gpl-btn-wide\" id=\"gpl-tool-clear\" title=\"Clear ruler + custom AB line; reset zoom to fit\" aria-label=\"Clear overlays and reset view\">\n        <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6\"\/><\/svg>\n        <span>Clear<\/span>\n      <\/button>\n      <button type=\"button\" class=\"gpl-tool-btn gpl-btn-wide\" id=\"gpl-tool-export\" title=\"Export guidance lines \u2014 Shapefile \/ GeoJSON \/ KML, ready for John Deere Operations Center, CNH FieldOps, AGCO PTx, Trimble\" aria-label=\"Export guidance lines\">\n        <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 3v12\"\/><polyline points=\"7,10 12,15 17,10\"\/><path d=\"M3 19h18\"\/><\/svg>\n        <span>Export<\/span>\n      <\/button>\n      <div class=\"gpl-export-menu\" id=\"gpl-export-menu\" hidden>\n        <button type=\"button\" data-fmt=\"shp\" title=\"Zipped shapefile (.shp \/ .shx \/ .dbf \/ .prj) \u2014 universal GIS format accepted by John Deere Operations Center, CNH FieldOps, AGCO PTx, Trimble Ag Software\">Shapefile (.zip)<\/button>\n        <button type=\"button\" data-fmt=\"geojson\" title=\"GeoJSON LineString collection \u2014 modern web standard, supported by JD Ops Center, QGIS, ArcGIS Online\">GeoJSON (.geojson)<\/button>\n        <button type=\"button\" data-fmt=\"kml\" title=\"Keyhole Markup Language \u2014 Google Earth + most field-management viewers\">KML (.kml)<\/button>\n      <\/div>\n      <button type=\"button\" class=\"gpl-tool-btn\" id=\"gpl-tool-zoom-in\" title=\"Zoom in\" aria-label=\"Zoom in\">+<\/button>\n      <button type=\"button\" class=\"gpl-tool-btn\" id=\"gpl-tool-zoom-out\" title=\"Zoom out\" aria-label=\"Zoom out\">\u2212<\/button>\n      <button type=\"button\" class=\"gpl-tool-btn\" id=\"gpl-tool-fit\" title=\"Fit field\" aria-label=\"Reset view\">\u27f2<\/button>\n    <\/div>\n    <div class=\"gpl-ruler-hint\" id=\"gpl-ruler-hint\">Click two points on the canvas to measure<\/div>\n    <div class=\"gpl-ruler-hint\" id=\"gpl-ab-hint\">Click the first point of your AB line \u00b7 ESC to cancel<\/div>\n    <aside class=\"gpl-floating-cta\" id=\"gpl-floating-cta\">\n      <div class=\"gpl-floating-cta-h\">Smarter guidance in GeoPard<\/div>\n      <ul>\n        <li>AI assistant \u2014 knows your fields + crop plan<\/li>\n        <li>Topography Follow with hi-res elevation (real DEM, not synthetic)<\/li>\n        <li>Auto-detect AB lines from hi-res satellite imagery<\/li>\n        <li>2-way sync: JD Ops, CNH FieldOps, AGCO PTx + API<\/li>\n        <li>Prescription maps aligned with AB lines<\/li>\n        <li>Export ISO-XML, shapefile, GeoJSON for any tractor<\/li>\n      <\/ul>\n      <a class=\"gpl-floating-cta-btn\" href=\"https:\/\/app.geopard.tech\/signup?utm_source=guidance-lines&amp;utm_medium=wp-embed&amp;utm_campaign=floating-cta\" target=\"_blank\" rel=\"noopener\">\n        Start free trial<span class=\"arr\">\u2192<\/span>\n      <\/a>\n    <\/aside>\n    <\/div>\n  <\/main>\n  <div class=\"gpl-splitter-right\" id=\"gpl-splitter-right\" aria-label=\"Resize analytics panel\" title=\"Drag to resize\"><\/div>\n  <aside class=\"gpl-side-right\" id=\"gpl-side-right\">\n    <div class=\"gpl-card\">\n      <h3>Analytics<\/h3>\n      <div class=\"gpl-unit-toggle\" role=\"tablist\" title=\"Switch unit system \u2014 converts all inputs + outputs\">\n        <button type=\"button\" class=\"gpl-unit-tab is-on\" data-unit-system=\"metric\">Metric<\/button>\n        <button type=\"button\" class=\"gpl-unit-tab\" data-unit-system=\"us\">US<\/button>\n      <\/div>\n      <div class=\"gpl-results\">\n        <div class=\"gpl-r-row\"><span class=\"gpl-r-k\">Field area<\/span><span class=\"gpl-r-v\" id=\"gpl-r-area\">\u2014 ha<\/span><\/div>\n        <div class=\"gpl-r-row gpl-r-pri\"><span class=\"gpl-r-k\">Passes<\/span><span class=\"gpl-r-v\" id=\"gpl-r-passes\">\u2014<\/span><\/div>\n        <div class=\"gpl-r-row\"><span class=\"gpl-r-k\">Productivity<\/span><span class=\"gpl-r-v\" id=\"gpl-r-prod\" title=\"Field area worked per hour at the configured working speed.\">\u2014 ha\/h<\/span><\/div>\n        <div class=\"gpl-r-row gpl-r-input\"><span class=\"gpl-r-k\" data-u=\"speed-label\">Working speed<\/span><span class=\"gpl-r-ctl\"><input type=\"number\" id=\"gpl-speed\" min=\"2\" max=\"30\" step=\"0.5\" value=\"10\"><span class=\"gpl-unit\" data-u=\"speed\">km\/h<\/span><\/span><\/div>\n        <div class=\"gpl-r-section\">Time<\/div>\n        <div class=\"gpl-r-row\"><span class=\"gpl-r-k\">Total<\/span><span class=\"gpl-r-v\" id=\"gpl-r-time-total\">\u2014<\/span><\/div>\n        <div class=\"gpl-r-row\"><span class=\"gpl-r-k gpl-r-sub\">Working<\/span><span class=\"gpl-r-v\" id=\"gpl-r-time-work\">\u2014<\/span><\/div>\n        <div class=\"gpl-r-row\"><span class=\"gpl-r-k gpl-r-sub\">Non-working<\/span><span class=\"gpl-r-v\" id=\"gpl-r-time-trans\" title=\"Time spent on turnarounds and transport between disconnected field parts. Lower means more time on the implement, less idling.\">\u2014<\/span><\/div>\n        <div class=\"gpl-r-section\">Distance<\/div>\n        <div class=\"gpl-r-row\"><span class=\"gpl-r-k\">Total<\/span><span class=\"gpl-r-v\" id=\"gpl-r-drive\">\u2014 km<\/span><\/div>\n        <div class=\"gpl-r-row\"><span class=\"gpl-r-k gpl-r-sub\">Working<\/span><span class=\"gpl-r-v\" id=\"gpl-r-len\">\u2014 km<\/span><\/div>\n        <div class=\"gpl-r-row\"><span class=\"gpl-r-k gpl-r-sub\">Non-working<\/span><span class=\"gpl-r-v\" id=\"gpl-r-turnlen\">\u2014 km<\/span><\/div>\n        <div class=\"gpl-r-row\"><span class=\"gpl-r-k gpl-r-sub\">Turnarounds<\/span><span class=\"gpl-r-v\" id=\"gpl-r-turns\">\u2014<\/span><\/div>\n        <div class=\"gpl-r-section\">Coverage<\/div>\n        <div class=\"gpl-r-row gpl-r-pri\"><span class=\"gpl-r-k\">Covered<\/span><span class=\"gpl-r-v\" id=\"gpl-r-cov\">\u2014 %<\/span><\/div>\n        <div class=\"gpl-r-row\"><span class=\"gpl-r-k gpl-r-sub\">Overlap area<\/span><span class=\"gpl-r-v\" id=\"gpl-r-overlap-ha\" title=\"Ground driven over twice or more. Lower = less compaction, less wasted diesel. Rule \u00a76 \u2014 minimise wheel-pass repeats.\">\u2014 ha<\/span><\/div>\n        <div class=\"gpl-r-row\"><span class=\"gpl-r-k gpl-r-sub\">Missed zone<\/span><span class=\"gpl-r-v\" id=\"gpl-r-missed\" title=\"Field area not touched by any swath. Lower = more complete coverage.\">\u2014 ha<\/span><\/div>\n        <div class=\"gpl-r-row\"><span class=\"gpl-r-k\">Path crossings<\/span><span class=\"gpl-r-v\" id=\"gpl-r-crossings\" title=\"How often the drive path crosses itself (excluding unavoidable pass-to-ring junctions). Each crossing is a wheel-track repeat. Rule \u00a76 \u2014 minimise path crossings.\">\u2014<\/span><\/div>\n        <div class=\"gpl-r-section\">Cost<\/div>\n        <div class=\"gpl-r-row gpl-r-pri\"><span class=\"gpl-r-k\">Fuel<\/span><span class=\"gpl-r-v\" id=\"gpl-r-fuel\">\u2014 L<\/span><\/div>\n        <div class=\"gpl-r-row\"><span class=\"gpl-r-k gpl-r-sub\">Turn fuel<\/span><span class=\"gpl-r-v\" id=\"gpl-r-turnfuel\">\u2014 L<\/span><\/div>\n        <div class=\"gpl-r-row\"><span class=\"gpl-r-k gpl-r-sub\">Slope cost<\/span><span class=\"gpl-r-v\" id=\"gpl-r-slope\">+ 0%<\/span><\/div>\n        <div class=\"gpl-r-row\"><span class=\"gpl-r-k\">Avg grade<\/span><span class=\"gpl-r-v\" id=\"gpl-r-grade\">\u2014 %<\/span><\/div>\n        <div class=\"gpl-r-row gpl-r-input\"><span class=\"gpl-r-k\" data-u=\"consumption-label\">L\/km<\/span><span class=\"gpl-r-ctl\"><input type=\"number\" id=\"gpl-cons\" min=\"0.1\" max=\"5\" step=\"0.1\" value=\"0.6\"><\/span><\/div>\n        <div class=\"gpl-r-row gpl-r-input\"><span class=\"gpl-r-k\">Diesel price<\/span><span class=\"gpl-r-ctl\"><input type=\"number\" id=\"gpl-fuel\" min=\"0.1\" max=\"3\" step=\"0.05\" value=\"1.20\"><select id=\"gpl-currency\" class=\"gpl-cur-sel\" data-u=\"currency\"><option value=\"usd\" selected>$\/L<\/option><option value=\"eur\">\u20ac\/L<\/option><\/select><\/span><\/div>\n        <div class=\"gpl-r-row\"><span class=\"gpl-r-k\">Cost<\/span><span class=\"gpl-r-v\" id=\"gpl-r-cost\">$ \u2014<\/span><\/div>\n        <div class=\"gpl-r-row\"><span class=\"gpl-r-k\">CO\u2082<\/span><span class=\"gpl-r-v\" id=\"gpl-r-co2\" title=\"Tractor exhaust CO\u2082 from the diesel burned on this plan, at ~2.68 kg CO\u2082 per litre (standard agricultural diesel emission factor).\">\u2014 kg<\/span><\/div>\n        <div class=\"gpl-r-row\"><span class=\"gpl-r-k\">vs AB straight<\/span><span class=\"gpl-r-savings\" id=\"gpl-r-sav\">\u2014<\/span><\/div>\n      <\/div>\n    <\/div>\n  <\/aside>\n<\/div>\n\n<!-- Compare All + Annual ROI moved out of the side panel so the canvas\n     and analytics get full vertical space. Two-column layout on\n     widescreen, stacks vertically on tablet \/ phone. The \"Your existing\"\n     row inside Compare All toggles via JS when the user has uploaded\n     their own guidance lines (UPLOADED_LINES populated). -->\n<div class=\"gpl-below\" id=\"gpl-below\">\n  <div class=\"gpl-card gpl-below-cmp\">\n    <h3>Compare all approaches<\/h3>\n    <div class=\"gpl-cmp\" id=\"gpl-cmp-table\" role=\"grid\">\n      <div class=\"gpl-cmp-row gpl-cmp-head\" role=\"row\">\n        <span class=\"gpl-cmp-sort\" data-sort=\"name\" title=\"Sort by approach name (alphabetical)\">Approach<span class=\"gpl-cmp-arr\"><\/span><\/span>\n        <span class=\"gpl-cmp-val gpl-cmp-sort\" data-sort=\"cov\" title=\"Field area covered by at least one pass swath. Higher is better.\">Coverage<span class=\"gpl-cmp-arr\"><\/span><\/span>\n        <span class=\"gpl-cmp-val gpl-cmp-sort\" data-sort=\"missed\" title=\"Field area not touched by any swath. Lower is better.\">Missed area<span class=\"gpl-cmp-arr\"><\/span><\/span>\n        <span class=\"gpl-cmp-val gpl-cmp-sort\" data-sort=\"compaction\">Area at risk<span class=\"gpl-cmp-arr\"><\/span><span class=\"gpl-info-ico gpl-cmp-info\" data-tip=\"Soil compaction risk zone \u2014 the area where the operator's wheel tracks drove over the SAME ground 2\u00d7 or more. Repeat passes compact soil, reduce root depth + water infiltration, cut next-season yield 5\u201315 % on the compacted band, and accelerate runoff + erosion. Long-term issue: heavy compaction takes 2\u20133 seasons of recovery (deep tillage, cover-cropping) to undo. Reported in hectares (or acres). Lower = healthier soil + better next-season yield.\">i<\/span><\/span>\n        <span class=\"gpl-cmp-val gpl-cmp-sort\" data-sort=\"repeats\" title=\"Percentage of the worked area driven over twice or more (overlap %). Lower is better.\">Repeats<span class=\"gpl-cmp-arr\"><\/span><\/span>\n        <span class=\"gpl-cmp-val gpl-cmp-sort\" data-sort=\"cross\" title=\"Number of points where the drive path crosses itself. Each crossing is a wheel-track repeat. Lower is better.\">Path crossings<span class=\"gpl-cmp-arr\"><\/span><\/span>\n        <span class=\"gpl-cmp-val gpl-cmp-sort\" data-sort=\"km\" title=\"Total drive distance per field (matches the Analytics unit setting). Lower is better.\">Distance<span class=\"gpl-cmp-arr\"><\/span><\/span>\n        <span class=\"gpl-cmp-val gpl-cmp-sort\" data-sort=\"turns\" title=\"Number of headland turnarounds. Lower is better \u2014 every turn lifts the implement.\">Turnarounds<span class=\"gpl-cmp-arr\"><\/span><\/span>\n        <span class=\"gpl-cmp-val gpl-cmp-sort\" data-sort=\"time\" title=\"Total time to work the field at the configured working speed. Lower is better.\">Time<span class=\"gpl-cmp-arr\"><\/span><\/span>\n        <span class=\"gpl-cmp-val gpl-cmp-sort\" data-sort=\"fuel\" title=\"Fuel consumption per field at the configured L\/km. Lower is better.\">Fuel<span class=\"gpl-cmp-arr\"><\/span><\/span>\n        <span class=\"gpl-cmp-val gpl-cmp-sort\" data-sort=\"cost\" title=\"Total fuel cost per field at the configured price. Lower is better.\">Cost<span class=\"gpl-cmp-arr\"><\/span><\/span>\n      <\/div>\n      <div class=\"gpl-cmp-row\" data-cmp=\"ab-straight\" role=\"row\">\n        <span class=\"gpl-cmp-name\">AB Straight<\/span>\n        <span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span>\n      <\/div>\n      <div class=\"gpl-cmp-row\" data-cmp=\"ab-curve\" role=\"row\">\n        <span class=\"gpl-cmp-name\">AB Curve<\/span>\n        <span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span>\n      <\/div>\n      <div class=\"gpl-cmp-row\" data-cmp=\"boundary\" role=\"row\">\n        <span class=\"gpl-cmp-name\">Boundary Follow<\/span>\n        <span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span>\n      <\/div>\n      <div class=\"gpl-cmp-row\" data-cmp=\"adaptive\" role=\"row\">\n        <span class=\"gpl-cmp-name\">Topography follow<\/span>\n        <span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span>\n      <\/div>\n      <div class=\"gpl-cmp-row\" data-cmp=\"auto-blocks\" role=\"row\">\n        <span class=\"gpl-cmp-name\">Auto-blocks<\/span>\n        <span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span>\n      <\/div>\n      <div class=\"gpl-cmp-row gpl-cmp-uploaded\" data-cmp=\"uploaded\" hidden role=\"row\">\n        <span class=\"gpl-cmp-name\">Your current lines<\/span>\n        <span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span>\n      <\/div>\n    <\/div>\n    <div class=\"gpl-cmp-best\" id=\"gpl-cmp-best\">\n      <span class=\"gpl-cmp-best-badge\">Best for this field<\/span>\n      <span class=\"gpl-cmp-best-name\" id=\"gpl-cmp-best-name\">\u2014<\/span>\n      <span class=\"gpl-cmp-best-why\" id=\"gpl-cmp-best-why\">analysing\u2026<\/span>\n    <\/div>\n    <div class=\"gpl-cmp-hint\" id=\"gpl-cmp-uploaded-hint\" hidden>Upload your existing guidance lines to compare the four proposed approaches against your current plan, head-to-head on the same field.<\/div>\n  <\/div>\n<\/div>\n\n<script nowprocket data-no-optimize=\"1\" data-no-defer=\"1\" data-no-minify=\"1\">\n(function(){\n  var root = document.getElementById('gpl-root');\n  if(!root) return;\n  var canvas = document.getElementById('gpl-canvas');\n  var ctx = canvas.getContext('2d');\n  var trayEl = document.getElementById('gpl-tray');\n  \/\/ Display names for the four approaches \u2014 used when surfacing the\n  \/\/ recommendation's stats so the user sees WHICH option the numbers\n  \/\/ (\"86% cov \u00b7 125 turns \u00b7 68.4 L\") refer to instead of guessing.\n  var AP_LABELS = {\n    'ab-straight': 'AB Straight',\n    'ab-curve':    'AB Curve',\n    'boundary':    'Boundary Follow',\n    'adaptive':    'Topography follow'\n  };\n  \/\/ Sample fields \u2014 units are real meters, rendered with a uniform scale fitting\n  \/\/ the canvas. Four shapes cover the common cases: irregular polygon, rectangular\n  \/\/ Midwest section, L-shaped (obstacle \/ hill corner), oval (centre-pivot remnant).\n  var FIELDS = {\n    hex: [\n      { x:  20, y: 220 }, { x:  80, y:  60 }, { x: 290, y:  35 },\n      { x: 580, y:  90 }, { x: 690, y: 240 }, { x: 640, y: 460 },\n      { x: 380, y: 540 }, { x: 130, y: 510 }, { x:  40, y: 380 }\n    ],\n    rect: [\n      { x:  40, y:  60 }, { x: 720, y:  70 }, { x: 730, y: 520 }, { x:  50, y: 510 }\n    ],\n    lshape: [\n      \/\/ Exaggerated slim L \u2014 top arm ~140 m thick \u00d7 600 m long\n      \/\/ (horizontal-trending), side arm ~140 m thick \u00d7 480 m long\n      \/\/ (vertical-trending). 90\u00b0 clean inner reflex. Slim arms make\n      \/\/ any global-axis approach (AB Straight, Topography) waste big\n      \/\/ chunks of fuel on miss-oriented passes, so the per-block axis\n      \/\/ of Auto-blocks wins by a clear, visible margin.\n      \/\/ Going clockwise from upper-left:\n      { x:  25, y:  50 }, { x: 190, y:  25 }, { x: 410, y:  30 },\n      { x: 575, y:  55 }, { x: 615, y: 100 },   \/\/ top-right of arm 1\n      { x: 640, y: 220 }, { x: 660, y: 380 }, { x: 645, y: 490 },\n      { x: 605, y: 555 },                       \/\/ bottom-right of arm 2\n      { x: 500, y: 565 }, { x: 410, y: 545 },\n      { x: 425, y: 420 }, { x: 440, y: 290 },\n      { x: 455, y: 195 },                       \/\/ INNER concave reflex (90\u00b0)\n      { x: 320, y: 175 }, { x: 165, y: 170 },\n      { x:  25, y: 165 }\n    ],\n    \/\/ Irrigation pivot field \u2014 a true circle drawn by the centre-pivot\n    \/\/ sprinkler. Radius \u2248 300 m gives ~28 ha which matches a typical\n    \/\/ quarter-section pivot (130 m wide, 360\u00b0 coverage, ~32 ha gross \/\n    \/\/ ~28 ha actually wetted).\n    pivot: (function(){\n      var pts = [];\n      var cx = 380, cy = 290, r = 300;\n      for(var i=0; i<28; i++){\n        var t = (i \/ 28) * Math.PI * 2;\n        pts.push({ x: cx + Math.cos(t) * r, y: cy + Math.sin(t) * r });\n      }\n      return pts;\n    })(),\n    \/\/ Two-block field \u2014 two disconnected IRREGULAR polygons with\n    \/\/ dominant axes ~55\u00b0 apart (not perpendicular \u2014 real adjacent\n    \/\/ operational fields rarely line up at a clean 90\u00b0). Polygon A\n    \/\/ (~6 ha, axis tilted ~\u221213\u00b0 from +x) and B (~6 ha, axis ~+110\u00b0)\n    \/\/ have shapes that aren't rectangular either, so the demo reads\n    \/\/ as a real field, not a textbook diagram. A global field axis\n    \/\/ splits the difference and works poorly on both; Auto-blocks\n    \/\/ gives each polygon its own PCA axis \u2192 only approach that hits\n    \/\/ high coverage on BOTH.\n    twoblock: [\n      \/\/ Bbox encompasses both elongated blocks (used for default canvas fit).\n      { x:  30, y: 270 }, { x:  20, y:  80 }, { x: 240, y:  50 },\n      { x: 690, y:  80 }, { x: 720, y: 250 }, { x: 480, y: 295 },\n      { x: 200, y: 290 }\n    ]\n  };\n  \/\/ Multi-part parts for fields with disconnected polygons. Looked up\n  \/\/ by the field-switch handler; absent entries default to [BOUNDARY].\n  var FIELD_PARTS = {\n    twoblock: [\n      \/\/ Block A \u2014 long horizontal arm ~9 vertices, aspect ~3.0 (700 m \u00d7 235 m),\n      \/\/ long axis ~\u22125\u00b0 from +x. The exaggerated elongation makes the\n      \/\/ global-axis approaches waste a clearly visible chunk on Block B\n      \/\/ and gives auto-blocks an obvious win \u2014 each block's own PCA\n      \/\/ axis tiles tight against the long dimension.\n      [\n        { x:  20, y: 230 }, { x:  20, y:  90 }, { x: 220, y:  50 },\n        { x: 460, y:  55 }, { x: 690, y:  80 }, { x: 720, y: 220 },\n        { x: 530, y: 280 }, { x: 280, y: 295 }, { x: 100, y: 285 }\n      ],\n      \/\/ Block B \u2014 long vertical arm, aspect ~2.5 (220 m \u00d7 540 m), long\n      \/\/ axis ~80\u00b0 from +x (i.e. axes differ from Block A by ~85\u00b0). Tall\n      \/\/ shape so AB Straight + Topography aligned to A's horizontal axis\n      \/\/ leaves wide diagonal gaps inside B \u2014 auto-blocks aligns B to its\n      \/\/ own vertical PCA and covers \u2265 95 %.\n      [\n        { x: 780, y:  60 }, { x: 920, y:  90 }, { x: 970, y: 240 },\n        { x: 985, y: 420 }, { x: 940, y: 580 }, { x: 830, y: 605 },\n        { x: 740, y: 540 }, { x: 720, y: 380 }, { x: 730, y: 220 },\n        { x: 745, y: 100 }\n      ]\n    ]\n  };\n  var currentField = 'lshape';\n  var BOUNDARY = FIELDS[currentField];\n  \/\/ Multi-part field model. BOUNDARY_PARTS is always an array of polygon vertex\n  \/\/ arrays. For built-in fields and single-polygon imports, length === 1.\n  \/\/ For multi-polygon imports (shapefile with multiple outer rings, GeoJSON\n  \/\/ MultiPolygon, KML with multiple Placemarks), each ring becomes a part and\n  \/\/ the machine works each one in turn with a transport leg between them.\n  \/\/ Real-world rule: a tractor cannot easily drive between disconnected field\n  \/\/ parts (forest \/ river \/ road in between) \u2014 so we finish one part fully\n  \/\/ BEFORE starting the next, and visualise the inter-part hop as a gray\n  \/\/ dashed \"transport\" segment with no swath behind it.\n  var BOUNDARY_PARTS = [BOUNDARY];\n  \/\/ Per-field synthetic elevation. terrain(x, y) \u2192 metres above the field's\n  \/\/ own lowest point. Calibrated to demonstrate the real ROI of contour\n  \/\/ planning: AB-straight grinds across these ~6-10% grades and pays the\n  \/\/ full slope-penalty fuel surcharge, while Contour-follow drives along\n  \/\/ the iso-z lines and pays ~0% slope penalty.\n  \/\/   hex     \u2014 east-facing slope, 35 m drop across ~670 m (\u2248 5% mean)\n  \/\/   rect    \u2014 diagonal ridge, 40 m peak (\u2248 7% on the flanks)\n  \/\/   lshape  \u2014 hill at the concave corner (NE), 50 m peak (\u2248 10% locally)\n  \/\/   pivot   \u2014 single round hill, 45 m peak in centre (\u2248 8% on the slopes)\n  \/\/   custom  \u2014 flat (no elevation data for uploaded boundaries)\n  \/\/ The functions are deliberately smooth so contour lines look natural.\n  var TERRAIN = {\n    hex: function(x, y){\n      \/\/ Tilted plane + gentle waves\n      return 35 * ((x - 20) \/ 670) + 4 * Math.sin(y \/ 90) + 2 * Math.cos(x \/ 110);\n    },\n    rect: function(x, y){\n      \/\/ Diagonal ridge\n      var u = (x + y) \/ 1200;\n      return 40 * Math.exp(-Math.pow(u - 0.5, 2) * 8) - 5;\n    },\n    lshape: function(x, y){\n      \/\/ Hill near the concave corner (~455, 195) of the slim L-shape,\n      \/\/ plus a gentle southwest tilt.\n      var dx = (x - 455) \/ 200, dy = (y - 195) \/ 180;\n      return 45 * Math.exp(-(dx*dx + dy*dy)) + (x - 25) * 0.025;\n    },\n    pivot: function(x, y){\n      \/\/ Central round hill\n      var dx = (x - 380) \/ 220, dy = (y - 290) \/ 220;\n      return 45 * Math.exp(-(dx*dx + dy*dy));\n    },\n    twoblock: function(x, y){\n      \/\/ Two LOCAL elevation features \u2014 one centred on each block \u2014 so\n      \/\/ the heatmap is visible across both polygons but the gradients\n      \/\/ POINT in DIFFERENT directions per block. A global axis picked\n      \/\/ by Topography Follow can only satisfy one block; auto-blocks\n      \/\/ (per-block axis) is the only approach that fits both. Avoids\n      \/\/ a uniform slope that would let Topography mis-win the demo.\n      var dxA = (x - 240) \/ 200, dyA = (y - 170) \/ 150;\n      var dxB = (x - 580) \/ 180, dyB = (y - 290) \/ 250;\n      \/\/ Hill on Block A (gentle, centred upper-left), dip on Block B\n      \/\/ (centred lower-right). Combined range ~14 m \u2014 enough for the\n      \/\/ 0.5 m drawTerrain visibility threshold to fire.\n      return 14 * Math.exp(-(dxA*dxA + dyA*dyA)) - 10 * Math.exp(-(dxB*dxB + dyB*dyB));\n    },\n    custom: function(){ return 0; }\n  };\n  function terrainAt(x, y){\n    var fn = TERRAIN[currentField] || TERRAIN.custom;\n    return fn(x, y);\n  }\n  \/\/ AB-line direction override. null = use PCA result; otherwise degrees 0\u2013180\n  \/\/ measured from the +x axis (counter-clockwise in screen-coords where +y is\n  \/\/ down, so this matches \"compass-like\" intuitive direction on the canvas).\n  var userAxisDeg = null;\n  \/\/ AB-by-coordinates anchor \u2014 when the user pins an AB direction via\n  \/\/ GPS coordinates, store the projected canvas coords of point A here.\n  \/\/ The parallel-pass grid snaps so one pass goes EXACTLY through the\n  \/\/ anchor (= the operator's recorded A point) instead of just borrowing\n  \/\/ the bearing. Cleared on slider input, Auto reset, field switch, and\n  \/\/ new imports. {x, y} canvas coords or null.\n  var userAxisAnchor = null;\n  \/\/ Heading strategy \u2014 which of the 4 options the user picked. 'longest'\n  \/\/ is the geometric default (PCA\/longest-edge angle); 'crossings' and\n  \/\/ 'distance' compute their angles via a small angle sweep against the\n  \/\/ current approach + field; 'custom' lets the user dial in any angle\n  \/\/ via the slider below.\n  var headingStrategy = 'longest';\n  \/\/ Deferred Compare All work \u2014 cleared when a fresh recompute fires.\n  var compareTimer = null;\n  var headingAngles = { longest: 0, crossings: null, distance: null, time: null };\n  \/\/ Auto-blocks per-block angle overrides. Key = block centroid\n  \/\/ rounded to 5 m (stable across recomputes as long as the polygon\n  \/\/ doesn't change). Value = angle in degrees [0, 180). When set,\n  \/\/ generateLinesAll uses this instead of the block's PCA axis.\n  var BLOCK_AXIS_OVERRIDES = {};\n  function blockCentroidKey(block){\n    var cx = 0, cy = 0;\n    for(var bki=0; bki<block.length; bki++){\n      cx += block[bki].x;\n      cy += block[bki].y;\n    }\n    cx \/= block.length; cy \/= block.length;\n    return Math.round(cx \/ 5) * 5 + ',' + Math.round(cy \/ 5) * 5;\n  }\n  function fieldStats(b){\n    var minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;\n    for(var i=0; i<b.length; i++){\n      if(b[i].x < minX) minX = b[i].x;\n      if(b[i].x > maxX) maxX = b[i].x;\n      if(b[i].y < minY) minY = b[i].y;\n      if(b[i].y > maxY) maxY = b[i].y;\n    }\n    var s = 0;\n    for(var j=0; j<b.length; j++){\n      var k = (j + 1) % b.length;\n      s += b[j].x * b[k].y - b[k].x * b[j].y;\n    }\n    return { minX: minX, maxX: maxX, minY: minY, maxY: maxY, area: Math.abs(s) * 0.5 };\n  }\n  \/\/ Combined stats across multiple boundary parts \u2014 union bbox + total area.\n  \/\/ Used by recompute() so a multi-part import reports the SUM of part areas\n  \/\/ (not just the largest part).\n  function fieldStatsAll(parts){\n    if(!parts ? true : parts.length === 0) return { minX: 0, maxX: 0, minY: 0, maxY: 0, area: 0 };\n    var minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity, area = 0;\n    for(var i=0; i<parts.length; i++){\n      var st = fieldStats(parts[i]);\n      if(st.minX < minX) minX = st.minX;\n      if(st.maxX > maxX) maxX = st.maxX;\n      if(st.minY < minY) minY = st.minY;\n      if(st.maxY > maxY) maxY = st.maxY;\n      area += st.area;\n    }\n    return { minX: minX, maxX: maxX, minY: minY, maxY: maxY, area: area };\n  }\n  \/\/ Recommend an approach by computing real metrics on the current field for\n  \/\/ each candidate, then scoring against five operator priorities:\n  \/\/   1. coverage % \u2014 bad coverage means uncovered acres\n  \/\/   2. overlap % \u2014 double-covered ground = wasted diesel + compaction\n  \/\/   3. crossings \u2014 paths that visibly cross are wheel-track repeats\n  \/\/   4. turnaround count \u2014 fewer turns = less time + fuel\n  \/\/   5. fuel L \u2014 total drive \u00d7 consumption\n  \/\/\n  \/\/ Score = coverage \u2212 0.4 \u00d7 turns \u2212 1.5 \u00d7 fuel \u2212 0.8 \u00d7 overlap \u2212 0.6 \u00d7 crossings.\n  \/\/ Coverage \/ overlap are 0\u2013100; turns 10\u201330; fuel 5\u201320 L; crossings 0\u201330\n  \/\/ typically; weights bring them into similar ranges so coverage still\n  \/\/ dominates while the new compaction signals can break ties between\n  \/\/ otherwise-equal approaches. (Used as the \"why\" string \u2014 the actual\n  \/\/ recommendByMetrics tier rules below apply hard filters on crossings.)\n  \/\/\n  \/\/ Falls back to the shape-based heuristic only when metric eval fails\n  \/\/ (e.g., zero-pass result). recommendByMetrics is called from recompute\n  \/\/ AFTER the four Compare All layouts have already been computed, so we\n  \/\/ don't pay the cost of generating layouts twice.\n  function scoreApproach(met){\n    var cov = met.coveragePct || 0;\n    var turns = met.turns || 0;\n    var fuel = met.fuelL || 0;\n    var overlap = met.overlapPct || 0;\n    var crossings = met.crossings || 0;\n    \/\/ Reweighted 2026-06-03 after user observed Boundary Follow (11\n    \/\/ crossings, 96 % coverage) beating Topography Follow (46 crossings,\n    \/\/ 99 % coverage) on Hex AND Rect, even when Topo had the lowest fuel\n    \/\/ + cost. Old weights (turns 0.4, fuel 1.5, overlap 0.8, crossings\n    \/\/ 0.6) over-penalised approaches with structurally-higher crossing\n    \/\/ counts (Topography's centre-fill is curved by design \u2014 rule \u00a76),\n    \/\/ and under-valued the agronomic reality that sub-99 % coverage\n    \/\/ means the operator has to come back for the uncovered acres.\n    \/\/\n    \/\/ New weights:\n    \/\/   coverage penalty \u2014 hyperbolic below 99 %. Every percentage\n    \/\/                  point below 99 costs 5 score points (so 96 % cov\n    \/\/                  drops 15 pts vs 99 %). Real uncovered acres\n    \/\/                  matter much more than the linear (cov \u00d7 1) term\n    \/\/                  alone would suggest.\n    \/\/   fuel 4.5     \u2014 bottom-line $$, the metric the operator feels.\n    \/\/                  Tripled from 1.5 so a 1 L\/ha saving outweighs\n    \/\/                  ~10 extra turns + a few extra crossings.\n    \/\/   turns 0.1    \u2014 operator-fatigue, small compared to fuel.\n    \/\/   crossings 0.15 \u2014 penalised but no longer dominant. Combined\n    \/\/                  with the per-approach CROSS_LIMIT_BY_AP gate\n    \/\/                  in recommendByMetrics, this prevents pathological\n    \/\/                  high-crossing winners while letting Topography's\n    \/\/                  structurally-curved routing compete on real merit.\n    \/\/   overlap 0.8  \u2014 overlapping work is real wasted diesel + soil\n    \/\/                  compaction (rule \u00a76).\n    var covPenalty = cov < 99 ? (99 - cov) * 10 : 0;\n    \/\/ Fuel weight 6.5 (bumped from 4.5, 2026-06-03) \u2014 the user explicitly\n    \/\/ asked to weight cost more heavily. A 1 L\/ha fuel saving now costs\n    \/\/ 6.5 pts in the weighted sum, enough to (a) keep Topography winning\n    \/\/ on Hex\/Rect where it's cheapest AND (b) flip auto-blocks ahead of\n    \/\/ tied alternatives on the L-shape + 2-block fields where its\n    \/\/ per-block axis is the only way to land both low fuel + low turns.\n    return cov - covPenalty - 0.1 * turns - 6.5 * fuel - 0.8 * overlap - 0.15 * crossings;\n  }\n  \/\/ Tier-based recommendation with slope-savings override:\n  \/\/   1. If any approach reaches \u226595 % coverage \u2192 coverage tier = 95+.\n  \/\/      Otherwise tier = within 3 % of the best coverage.\n  \/\/   2. SLOPE OVERRIDE \u2014 if Contour-follow's fuel is \u2265 10 % lower than the\n  \/\/      best-in-tier approach's fuel AND its coverage is within 15 pp of\n  \/\/      the best, ADD it to the tier. This catches the case where\n  \/\/      Contour-follow's slope-aware routing saves real diesel ($) on a\n  \/\/      hilly field but loses a few coverage points to curved-pass tiling.\n  \/\/      Operators on a hill care more about fuel than 5 % uncovered\n  \/\/      corners they can finish manually.\n  \/\/   3. Inside the tier, pick the lowest-fuel approach. Ties: fewer\n  \/\/      turnarounds, then simpler approach (ab-straight > curve >\n  \/\/      boundary > adaptive).\n  function recommendByMetrics(apMetrics){\n    \/\/ Auto-blocks is included so concave \/ multi-part fields that genuinely\n    \/\/ benefit from sub-block decomposition can win on a balanced score.\n    var aps = ['ab-straight', 'ab-curve', 'boundary', 'adaptive', 'auto-blocks'];\n    var simplicity = { 'ab-straight': 0, 'ab-curve': 1, 'boundary': 2, 'adaptive': 3, 'auto-blocks': 4 };\n    var scores = {};\n    var candidates = [];\n    var maxCov = 0;\n    for(var i=0; i<aps.length; i++){\n      var ap = aps[i];\n      var met = apMetrics[ap];\n      if(!met) continue;\n      scores[ap] = scoreApproach(met);\n      candidates.push({\n        ap: ap,\n        score: scores[ap],\n        cov: met.coveragePct || 0,\n        fuel: met.fuelL || 0,\n        turns: met.turns || 0,\n        crossings: met.crossings || 0,\n        overlap: met.overlapPct || 0\n      });\n      if((met.coveragePct || 0) > maxCov) maxCov = met.coveragePct || 0;\n    }\n    if(candidates.length === 0) return { pick: 'ab-straight', why: 'fallback default', scores: scores };\n    \/\/ Multi-part bonus for auto-blocks. When the field has disconnected\n    \/\/ polygons (multi-part import, 2-block sample), auto-blocks's\n    \/\/ per-block axis IS the reason to pick it. Without a bonus, a tied\n    \/\/ score on flat multi-part terrain tips toward simpler approaches\n    \/\/ and the \"Auto-blocks beats global axis\" story doesn't land.\n    var partsCount = (typeof BOUNDARY_PARTS !== 'undefined' ? (BOUNDARY_PARTS ? BOUNDARY_PARTS.length : 1) : 1);\n    \/\/ Auto-blocks bonus fires when the FIELD has multiple disconnected\n    \/\/ parts (2-block sample) OR when auto-blocks INTERNALLY decomposed\n    \/\/ a single concave polygon into \u2265 2 sub-blocks (redesigned L-shape).\n    \/\/ Either case, the decomposition is the whole reason to pick\n    \/\/ auto-blocks \u2014 bonus tips the score so it wins over Topo\/Boundary\n    \/\/ on its signature use case.\n    var autoBlockSubCount = apMetrics['auto-blocks'] ? (apMetrics['auto-blocks'].blockCount || 1) : 1;\n    var multiBlockField = partsCount > 1 ? true : autoBlockSubCount > 1;\n    if(multiBlockField){\n      for(var mbi=0; mbi<candidates.length; mbi++){\n        if(candidates[mbi].ap === 'auto-blocks'){\n          \/\/ +25 (was +12) \u2014 needs to overcome Topography's terrain-driven\n          \/\/ coverage advantage on irregular fields with elevation\n          \/\/ (L-shape's hill at the concave corner gives adaptive +20 pts\n          \/\/ via cov + fuel). Auto-blocks should ALWAYS be the recommended\n          \/\/ pick when the field genuinely decomposed into \u2265 2 distinct\n          \/\/ sub-blocks \u2014 that's its signature win condition.\n          candidates[mbi].score = (candidates[mbi].score || 0) + 25;\n          scores['auto-blocks'] = candidates[mbi].score;\n        }\n      }\n    }\n    var covThreshold = maxCov >= 95 ? 95 : (maxCov - 3);\n    var tier = candidates.filter(function(c){ return c.cov >= covThreshold; });\n    \/\/ Auto-blocks on a TRULY decomposed field (blockCount > 1) is the\n    \/\/ canonical pick even if its coverage trails by a few pp \u2014 slim\n    \/\/ arms leave more uncovered headland strip relative to body area,\n    \/\/ so its coverage % naturally lags wider-armed designs. Admit\n    \/\/ auto-blocks into the tier if it's within 10 pp of the maxCov.\n    if(autoBlockSubCount > 1){\n      var abC = null;\n      for(var abi=0; abi<candidates.length; abi++){\n        if(candidates[abi].ap === 'auto-blocks'){ abC = candidates[abi]; break; }\n      }\n      if(abC ? tier.indexOf(abC) < 0 : false){\n        if(abC.cov >= maxCov - 10) tier.push(abC);\n      }\n    }\n    \/\/ Rule \u00a76 hard filter \u2014 an approach with too many path crossings or\n    \/\/ high overlap CANNOT be the \"Best\" pick, even if its coverage is\n    \/\/ highest. Per-approach crossings cap: Topography Follow's centre-fill\n    \/\/ generates curved passes that NATURALLY cross each other near\n    \/\/ headlands (rule \u00a76 \u2014 gradient-perpendicular AB-Straight + centre-\n    \/\/ fill). Reported 2026-06-03 \u2014 Topo lost to Boundary on Hex despite\n    \/\/ lowest fuel + cost because of a hard 8-cap that didn't fit its\n    \/\/ structural geometry. Give it 50 so it can compete on real merit.\n    var CROSS_LIMIT_BY_AP = {\n      'ab-straight': 8,\n      'ab-curve': 8,\n      \/\/ Per rule \u00a76: \"rare crossings \u2264 ~20 per Boundary-Follow combo\" \u2014\n      \/\/ Boundary's concentric-ring transitions naturally produce a few\n      \/\/ crossings as the spiral steps inward. 8 was too tight; 20 lines\n      \/\/ up with the agronomic rule of thumb.\n      'boundary': 20,\n      \/\/ Auto-blocks's inter-block transport legs CROSS pass swaths near\n      \/\/ the cut edge \u2014 structural, same as Boundary's ring transitions.\n      \/\/ 8 was too tight for multi-part fields (reported 2026-06-03 on\n      \/\/ the 2-block sample); 20 matches Boundary's allowance.\n      'auto-blocks': 20,\n      \/\/ Topography Follow's centre-fill is structurally curved (rule \u00a76)\n      \/\/ \u2192 naturally generates many crossings near headlands. Allow up to\n      \/\/ 60 so it can compete on real merit (lower fuel, higher coverage)\n      \/\/ on fields where it produces ~55 crossings (e.g. Rect centre-fill).\n      'adaptive': 60\n    };\n    var OVERLAP_LIMIT = 20;\n    var cleanTier = tier.filter(function(c){\n      var limit = CROSS_LIMIT_BY_AP[c.ap] || 8;\n      if(c.crossings > limit) return false;\n      if(c.overlap > OVERLAP_LIMIT) return false;\n      return true;\n    });\n    if(cleanTier.length > 0) tier = cleanTier;\n    \/\/ Slope-savings override \u2014 Topography (adaptive) gets added back if\n    \/\/ its fuel beats the tier's best by \u2265 5 % and its coverage is within\n    \/\/ 15 pp of the max. Real diesel savings on sloped fields beat the\n    \/\/ few uncovered corners the operator can finish manually. Threshold\n    \/\/ dropped from 10 % to 5 % (2026-06-03) \u2014 at 10 % the override\n    \/\/ didn't fire on fields with subtle topography even when Topo had\n    \/\/ measurably lower cost.\n    var tierBestFuel = Infinity;\n    for(var tb=0; tb<tier.length; tb++){\n      if(tier[tb].fuel < tierBestFuel) tierBestFuel = tier[tb].fuel;\n    }\n    var adaptiveC = null;\n    for(var ac=0; ac<candidates.length; ac++){\n      if(candidates[ac].ap === 'adaptive'){ adaptiveC = candidates[ac]; break; }\n    }\n    if(adaptiveC ? tier.indexOf(adaptiveC) < 0 : false){\n      var fuelSavingsPct = tierBestFuel > 0 ? ((tierBestFuel - adaptiveC.fuel) \/ tierBestFuel * 100) : 0;\n      var covGap = maxCov - adaptiveC.cov;\n      if(fuelSavingsPct >= 5 ? covGap <= 15 : false){\n        tier.push(adaptiveC);\n      }\n    }\n    \/\/ Clean tier \u2192 sort by the WEIGHTED scoreApproach (coverage, fuel,\n    \/\/ turns, overlap, crossings all factored in). Previously fuel was the\n    \/\/ sole primary key \u2014 a fuel-efficient plan with twice the crossings\n    \/\/ could win even though it caused more wheel-track damage. The\n    \/\/ combined score balances those signals.\n    \/\/\n    \/\/ Dirty fallback (every candidate failed the rule \u00a76 gate) keeps\n    \/\/ crossings as the primary key so the LEAST-BAD plan wins, not the\n    \/\/ cheapest.\n    var dirtyFallback = cleanTier.length === 0;\n    tier.sort(function(a, b){\n      if(dirtyFallback){\n        if(a.crossings !== b.crossings) return a.crossings - b.crossings;\n        if(a.overlap !== b.overlap) return a.overlap - b.overlap;\n        if(a.fuel !== b.fuel) return a.fuel - b.fuel;\n        return simplicity[a.ap] - simplicity[b.ap];\n      }\n      \/\/ Higher score wins (descending).\n      if(Math.abs(a.score - b.score) > 0.5) return b.score - a.score;\n      \/\/ Score tie (\u2264 0.5 pts apart) \u2192 prefer simpler approach so the user\n      \/\/ gets the most-familiar plan when alternatives are equivalent.\n      return simplicity[a.ap] - simplicity[b.ap];\n    });\n    var best = tier[0];\n    \/\/ FREE-PLAN PREFERENCE on multi-block fields (2026-06-10). When\n    \/\/ Topography wins ONLY through its slope-fuel advantage while\n    \/\/ Auto-blocks decomposed the same field (blockCount > 1) and matches\n    \/\/ it on the plan-quality metrics the operator can verify \u2014 coverage\n    \/\/ (within 1 pp), crossings, overlap \u2014 recommend Auto-blocks. Two\n    \/\/ reasons, both product-honest:\n    \/\/   1. Topography needs PRO elevation data; on imported fields it's\n    \/\/      locked. Auto-blocks is exportable + drivable TODAY. Recommend\n    \/\/      what the user can actually execute.\n    \/\/   2. Both approaches run the SAME per-block decomposition on these\n    \/\/      fields \u2014 Topography's fuel edge comes purely from the slope\n    \/\/      penalty model, not from a better path. On fields where\n    \/\/      Topography wins on COVERAGE too (rect, hex), it still wins.\n    if(best.ap === 'adaptive'){\n      var abCand = null;\n      for(var fpi=0; fpi<tier.length; fpi++){\n        if(tier[fpi].ap === 'auto-blocks'){ abCand = tier[fpi]; break; }\n      }\n      var abBlocks = apMetrics['auto-blocks'] ? (apMetrics['auto-blocks'].blockCount || 1) : 1;\n      if(abCand ? abBlocks > 1 : false){\n        var covClose = abCand.cov >= best.cov - 1.0;\n        var pathClean = abCand.crossings <= best.crossings + 2;\n        \/\/ +4 pp overlap tolerance (was +2): at 99 %+ coverage a few points\n        \/\/ of extra repeats is fringe noise, not a plan-quality gap worth\n        \/\/ surrendering the free-approach recommendation over (the\n        \/\/ fractional edge accounting of 2026-06-11 shifted per-approach\n        \/\/ overlap by \u00b12-4 pp and tripped the old tolerance).\n        var ovlClose = abCand.overlap <= best.overlap + 4;\n        if(covClose ? (pathClean ? ovlClose : false) : false){\n          best = abCand;\n        }\n      }\n    }\n    \/\/ Build a weighted \"why\" string that surfaces the TOP reasons this\n    \/\/ approach won \u2014 instead of just dumping every stat. Compute each\n    \/\/ metric's rank within the tier and call out where this approach\n    \/\/ leads (or is competitive). Falls back to the full stat dump if no\n    \/\/ clear leader emerges, so the user always sees real numbers.\n    function bestOf(arr, key, lowerIsBetter){\n      var best = lowerIsBetter ? Infinity : -Infinity;\n      for(var bi=0; bi<arr.length; bi++){\n        var v = arr[bi][key];\n        if(lowerIsBetter ? v < best : v > best) best = v;\n      }\n      return best;\n    }\n    var pool = candidates;  \/\/ compare against ALL candidates, not just tier\n    var topCov = bestOf(pool, 'cov', false);\n    var topFuel = bestOf(pool, 'fuel', true);\n    var topCross = bestOf(pool, 'crossings', true);\n    var topOverlap = bestOf(pool, 'overlap', true);\n    var topTurns = bestOf(pool, 'turns', true);\n    var wins = [];\n    if(best.cov >= topCov - 0.5) wins.push('highest coverage');\n    if(best.fuel <= topFuel + 0.2) wins.push('lowest fuel');\n    if(best.crossings <= topCross + 1) wins.push('cleanest path');\n    if(best.overlap <= topOverlap + 1) wins.push('least overlap');\n    if(best.turns <= topTurns + 1) wins.push('fewest turns');\n    var lead = wins.length >= 2 ? wins.slice(0, 2).join(' + ')\n             : wins.length === 1 ? wins[0]\n             : 'best weighted balance';\n    var stats = best.cov.toFixed(0) + ' % coverage \u00b7 ' + best.turns + ' turns \u00b7 ' +\n                best.fuel.toFixed(1) + ' L fuel \u00b7 ' + best.crossings + ' crossings \u00b7 ' +\n                best.overlap.toFixed(0) + ' % repeats';\n    var why = lead + ' \u2014 ' + stats;\n    return { pick: best.ap, why: why, scores: scores };\n  }\n  \/\/ Shape-based fallback (used at INITIAL load only, before any layout has\n  \/\/ run). Returns just the pick \u2014 recommendByMetrics replaces this on the\n  \/\/ first recompute().\n  function recommendApproach(b){\n    var stats = fieldStats(b);\n    var bboxArea = (stats.maxX - stats.minX) * (stats.maxY - stats.minY);\n    var rect = bboxArea > 0 ? (stats.area \/ bboxArea) : 0;\n    var per = 0;\n    for(var i=0; i<b.length; i++){\n      var j = (i + 1) % b.length;\n      var dx = b[j].x - b[i].x, dy = b[j].y - b[i].y;\n      per += Math.sqrt(dx*dx + dy*dy);\n    }\n    var comp = per > 0 ? (4 * Math.PI * stats.area \/ (per * per)) : 0;\n    var pick;\n    if(comp > 0.78) pick = 'boundary';\n    else if(rect > 0.85) pick = 'ab-straight';\n    else if(rect > 0.55) pick = 'ab-curve';\n    else pick = 'adaptive';\n    return { pick: pick, why: 'analysing\u2026' };\n  }\n  \/\/ Default field axis = direction of the LONGEST boundary edge (or the\n  \/\/ dominant edge-direction cluster for curved boundaries where no single\n  \/\/ edge is uniquely longest). Operators expect AB Straight passes to run\n  \/\/ PARALLEL to the longest field edge \u2014 that's the standard ag practice\n  \/\/ for rectangular and trapezoidal fields, and the natural axis even on\n  \/\/ L-shapes and irregular hex fields.\n  \/\/\n  \/\/ Algorithm:\n  \/\/   1. Bin every edge's direction (mod \u03c0) into 1\u00b0 buckets, weighted by\n  \/\/      edge length. The bucket with the most total edge-length defines\n  \/\/      the dominant axis direction.\n  \/\/   2. Use a weighted centroid inside the peak bin (over a \u00b13\u00b0 window)\n  \/\/      for sub-degree accuracy.\n  \/\/   3. Require the peak bucket to carry \u2265 25 % of all edge length \u2014\n  \/\/      otherwise no single direction dominates (Pivot's 28-sided circle,\n  \/\/      a curved-headland import, etc.) and we fall back to PCA so the\n  \/\/      axis still tracks the polygon's overall elongation.\n  function fieldAxis(b){\n    var n = b.length;\n    if(n < 3) return { ux: 1, uy: 0 };\n    var BINS = 180;  \/\/ 1\u00b0 resolution across [0, \u03c0)\n    var hist = new Float64Array(BINS);\n    var sumCos = new Float64Array(BINS);\n    var sumSin = new Float64Array(BINS);\n    var totalLen = 0;\n    for(var i=0; i<n; i++){\n      var j = (i + 1) % n;\n      var ex = b[j].x - b[i].x, ey = b[j].y - b[i].y;\n      var elen = Math.sqrt(ex * ex + ey * ey);\n      if(elen < 1e-6) continue;\n      \/\/ Direction mod \u03c0 so opposite edges (parallel sides) accumulate\n      \/\/ in the same bucket.\n      var dang = Math.atan2(ey, ex);\n      if(dang < 0) dang += Math.PI;\n      if(dang >= Math.PI) dang -= Math.PI;\n      var bIdx = Math.floor(dang \/ Math.PI * BINS);\n      if(bIdx >= BINS) bIdx = BINS - 1;\n      if(bIdx < 0) bIdx = 0;\n      hist[bIdx] += elen;\n      \/\/ Double-angle trick so circular mean inside the bin doesn't wrap\n      \/\/ (mod-\u03c0 directions live on a circle of period \u03c0, equivalent to\n      \/\/ mod-2\u03c0 after doubling).\n      var d2 = dang * 2;\n      sumCos[bIdx] += Math.cos(d2) * elen;\n      sumSin[bIdx] += Math.sin(d2) * elen;\n      totalLen += elen;\n    }\n    if(totalLen < 1e-6){\n      return { ux: 1, uy: 0 };\n    }\n    \/\/ Find peak bin (with \u00b13 neighbours folded in \u2014 narrow histogram\n    \/\/ bins can split a single direction across two buckets when the field\n    \/\/ axis lands near a degree boundary).\n    var peakIdx = 0;\n    var peakVal = 0;\n    for(var k=0; k<BINS; k++){\n      var s = hist[k];\n      for(var d=1; d<=3; d++){\n        s += hist[(k + d) % BINS];\n        s += hist[(k - d + BINS) % BINS];\n      }\n      if(s > peakVal){ peakVal = s; peakIdx = k; }\n    }\n    if(peakVal > totalLen * 0.25){\n      \/\/ Weighted centroid across the \u00b13 window for sub-bin accuracy.\n      var aggCos = 0, aggSin = 0;\n      for(var dd=-3; dd<=3; dd++){\n        var bb = (peakIdx + dd + BINS) % BINS;\n        aggCos += sumCos[bb];\n        aggSin += sumSin[bb];\n      }\n      var meanAng2 = Math.atan2(aggSin, aggCos);  \/\/ in [-\u03c0, \u03c0]\n      if(meanAng2 < 0) meanAng2 += 2 * Math.PI;\n      var ang = meanAng2 * 0.5;  \/\/ undo the 2\u00d7 doubling\n      return { ux: Math.cos(ang), uy: Math.sin(ang) };\n    }\n    \/\/ Fall back to PCA on vertex positions when no edge direction\n    \/\/ dominates (Pivot, curved upload boundary, etc.).\n    var mxs = 0, mys = 0;\n    for(var pi=0; pi<n; pi++){ mxs += b[pi].x; mys += b[pi].y; }\n    mxs \/= n; mys \/= n;\n    var sxx = 0, syy = 0, sxy = 0;\n    for(var pj=0; pj<n; pj++){\n      var dvx = b[pj].x - mxs, dvy = b[pj].y - mys;\n      sxx += dvx * dvx; syy += dvy * dvy; sxy += dvx * dvy;\n    }\n    var ang0 = 0.5 * Math.atan2(2 * sxy, sxx - syy);\n    return { ux: Math.cos(ang0), uy: Math.sin(ang0) };\n  }\n  \/\/ Concave-safe line clipper. Finds all edge intersections, sorts by t,\n  \/\/ and pairs them up: each consecutive (inside-entry, inside-exit) pair\n  \/\/ becomes one returned segment. Works for arbitrary simple polygons\n  \/\/ (convex or concave). Returns array of segments [{x0,y0,x1,y1}, ...]\n  \/\/ or null when the line misses the polygon entirely.\n  \/\/ Liang-Barsky was the old implementation; it ASSUMED convex and produced\n  \/\/ wrong clips on the L-shape interior (concave at the inner-corner).\n  function clipLineToBoundarySegments(x0, y0, x1, y1, b){\n    var dx = x1 - x0, dy = y1 - y0;\n    var nb = b.length;\n    var ts = [];\n    for(var i=0; i<nb; i++){\n      var j = (i + 1) % nb;\n      var ax = b[i].x, ay = b[i].y;\n      var ex = b[j].x - ax, ey = b[j].y - ay;\n      \/\/ Parametric line \u00d7 parametric edge: x0+t\u00b7dx = ax+u\u00b7ex etc.\n      \/\/ Solve for t, u in [0,1]. denom = dx\u00b7(-ey) \u2212 dy\u00b7(-ex) = dy\u00b7ex \u2212 dx\u00b7ey\n      var denom = dy * ex - dx * ey;\n      if(Math.abs(denom) < 1e-9) continue;\n      var u = (dx * (ay - y0) - dy * (ax - x0)) \/ denom;\n      var t = (ex * (ay - y0) - ey * (ax - x0)) \/ denom;\n      if(u < -1e-9 ? true : u > 1 + 1e-9) continue;\n      if(t < -1e-9 ? true : t > 1 + 1e-9) continue;\n      ts.push(t < 0 ? 0 : (t > 1 ? 1 : t));\n    }\n    if(ts.length < 2) return null;\n    ts.sort(function(a, b){ return a - b; });\n    \/\/ De-dup near-equal t's (line passing exactly through a vertex hits 2 edges)\n    var clean = [ts[0]];\n    for(var k=1; k<ts.length; k++){\n      if(ts[k] - clean[clean.length-1] > 1e-6) clean.push(ts[k]);\n    }\n    \/\/ Pair consecutive t's, but only keep pairs whose midpoint is INSIDE the\n    \/\/ polygon (filters away \"outside gaps\" between concave segments).\n    var segs = [];\n    for(var p=0; p+1<clean.length; p+=2){\n      var t0 = clean[p], t1 = clean[p+1];\n      var midT = (t0 + t1) * 0.5;\n      var mx = x0 + dx * midT, my = y0 + dy * midT;\n      if(!pointInPoly(mx, my, b)) continue;\n      if(t1 - t0 < 1e-6) continue;\n      segs.push({ x0: x0 + dx * t0, y0: y0 + dy * t0, x1: x0 + dx * t1, y1: y0 + dy * t1 });\n    }\n    return segs.length > 0 ? segs : null;\n  }\n  \/\/ Back-compat wrapper: return the LONGEST segment (or null). Callers that\n  \/\/ want all sub-segments (for proper L-shape coverage) use ...Segments above.\n  function clipLineToBoundary(x0, y0, x1, y1, b){\n    var segs = clipLineToBoundarySegments(x0, y0, x1, y1, b);\n    if(!segs) return null;\n    var best = null, bestLen = -1;\n    for(var i=0; i<segs.length; i++){\n      var s = segs[i];\n      var sdx = s.x1 - s.x0, sdy = s.y1 - s.y0;\n      var len = sdx*sdx + sdy*sdy;\n      if(len > bestLen){ bestLen = len; best = s; }\n    }\n    return best;\n  }\n  \/\/ Offset a closed polygon inward by distM. Returns array of {x,y} or null when collapse.\n  \/\/ Decompose a simple polygon into convex sub-polygons by splitting at\n  \/\/ each concave (reflex) vertex. For an L-shape (one concave vertex),\n  \/\/ returns two rectangles. For a polygon already convex (Rect, Hex,\n  \/\/ Pivot, most curved uploads), returns the polygon unchanged.\n  \/\/\n  \/\/ Why we need this: Boundary Follow on a concave polygon produces one\n  \/\/ chain of inward-offset rings that pinches at the concave corner \u2014\n  \/\/ the long arm gets fewer rings before the polygon collapses,\n  \/\/ dropping coverage in the middle. Per-convex-part rings tile each\n  \/\/ arm independently and recover full coverage. Per rule \u00a76 the\n  \/\/ operator transports across the split (no double-coverage).\n  \/\/\n  \/\/ Strategy: find the first concave vertex, cast a ray along one of\n  \/\/ its adjoining edges (extended past the vertex into the interior),\n  \/\/ find the first polygon edge the ray hits, split the polygon along\n  \/\/ that line, recurse on each piece. Defaults to returning the input\n  \/\/ polygon unchanged if no valid split is found.\n  \/\/ Decompose a (possibly multi-armed) concave polygon into \"blocks\"\n  \/\/ separated by the strongest reflex bends. Unlike decomposeIntoConvex\n  \/\/ (which insists each cut produce two fully convex halves and gives\n  \/\/ up otherwise), this version cuts at the strongest reflex it can\n  \/\/ find, then RECURSES on each half. Result: a 3-arm polygon yields 3\n  \/\/ blocks; a star yields N blocks; a slightly-concave shape stays as\n  \/\/ 1 block. Used by the 'auto-blocks' approach for per-block axis\n  \/\/ alignment. minSubArea (m\u00b2) prevents pathological micro-cuts on\n  \/\/ dense real-world boundaries.\n  function decomposeIntoBlocks(poly, minSubAreaM2, maxBlocks){\n    var maxN = maxBlocks > 0 ? maxBlocks : 8;\n    var minA = minSubAreaM2 > 0 ? minSubAreaM2 : 5000;  \/\/ 0.5 ha floor\n    function ringArea(b){\n      var s = 0;\n      for(var i=0; i<b.length; i++){\n        var j = (i + 1) % b.length;\n        s += b[i].x * b[j].y - b[j].x * b[i].y;\n      }\n      return Math.abs(s) * 0.5;\n    }\n    \/\/ Strongest reflex = the vertex whose interior angle is FURTHEST\n    \/\/ from 180\u00b0 on the concave side. Returns -1 when no significant\n    \/\/ bend exists (all interior angles within 30\u00b0 of straight).\n    function strongestReflex(b){\n      var n = b.length;\n      var w = 0;\n      for(var iw=0; iw<n; iw++){\n        var jw = (iw + 1) % n;\n        w += b[iw].x * b[jw].y - b[jw].x * b[iw].y;\n      }\n      var ccw = w > 0;\n      var bestI = -1, bestConcavity = 80;  \/\/ require \u2265 80\u00b0 bend (tightened\n      \/\/ 2026-06-03 from 65\u00b0 \u2014 on lv Landgut Parchau-Eichwiese the 65\u00b0\n      \/\/ threshold fired on subtle vertex bends in the imported polygon\n      \/\/ and created 3 spurious sub-blocks with axes 68\u00b0\/115\u00b0\/20\u00b0. The\n      \/\/ operator's eye reads the field as \"one direction\" \u2192 no splits.\n      \/\/ 80\u00b0 matches the L-shape's 90\u00b0 inner corner so true L-arm splits\n      \/\/ still pass; sub-perpendicular bends below it stay as one block.\n      for(var i=0; i<n; i++){\n        var prev = b[(i - 1 + n) % n], curr = b[i], next = b[(i + 1) % n];\n        var ax = curr.x - prev.x, ay = curr.y - prev.y;\n        var bx = next.x - curr.x, by = next.y - curr.y;\n        var cross = ax * by - ay * bx;\n        var dot = ax * bx + ay * by;\n        var isReflex = ccw ? cross < -1e-6 : cross > 1e-6;\n        if(!isReflex) continue;\n        var bendDeg = Math.atan2(Math.abs(cross), dot) * 180 \/ Math.PI;\n        if(bendDeg > bestConcavity){\n          bestConcavity = bendDeg;\n          bestI = i;\n        }\n      }\n      return bestI;\n    }\n    \/\/ Reuse decomposeIntoConvex's splitAtConcave-style logic but accept\n    \/\/ the cut even if sub-polys are still concave. We just need the cut\n    \/\/ to be valid geometry (no self-intersect, both sides have area).\n    function splitAtReflex(b, ci){\n      var n = b.length;\n      var v = b[ci];\n      var prev = b[(ci - 1 + n) % n];\n      var next = b[(ci + 1) % n];\n      var d1x = v.x - prev.x, d1y = v.y - prev.y;\n      var L1 = Math.sqrt(d1x * d1x + d1y * d1y);\n      var d2x = v.x - next.x, d2y = v.y - next.y;\n      var L2 = Math.sqrt(d2x * d2x + d2y * d2y);\n      if(L1 < 1e-3 ? true : L2 < 1e-3) return null;\n      \/\/ Try BOTH extending-edge rays AND the angle bisector pointing\n      \/\/ INTO the polygon. The bisector often yields a cleaner cut for\n      \/\/ sharp arms (3-direction farm boundaries).\n      var n1 = { dx: d1x \/ L1, dy: d1y \/ L1 };\n      var n2 = { dx: d2x \/ L2, dy: d2y \/ L2 };\n      var bxv = n1.dx + n2.dx, byv = n1.dy + n2.dy;\n      var bl = Math.sqrt(bxv * bxv + byv * byv);\n      var rays = [ n1, n2 ];\n      if(bl > 1e-6) rays.push({ dx: bxv \/ bl, dy: byv \/ bl });\n      var bestCut = null;\n      for(var r=0; r<rays.length; r++){\n        var ray = rays[r];\n        var best = null;\n        for(var i=0; i<n; i++){\n          if(i === ci) continue;\n          if(i === (ci - 1 + n) % n) continue;\n          var ea = b[i], eb = b[(i + 1) % n];\n          var ex = eb.x - ea.x, ey = eb.y - ea.y;\n          var denom = ray.dx * ey - ray.dy * ex;\n          if(Math.abs(denom) < 1e-9) continue;\n          var t = ((ea.x - v.x) * ey - (ea.y - v.y) * ex) \/ denom;\n          var u = ((ea.x - v.x) * ray.dy - (ea.y - v.y) * ray.dx) \/ denom;\n          if(t <= 1e-3) continue;\n          if(u < -1e-6 ? true : u > 1 + 1e-6) continue;\n          if(best === null ? true : t < best.t) best = { t: t, x: v.x + ray.dx * t, y: v.y + ray.dy * t, edgeIdx: i };\n        }\n        if(!best) continue;\n        \/\/ Build sub-polys\n        var polyA = [];\n        var ai = ci;\n        for(var step=0; step<n + 2; step++){\n          polyA.push({ x: b[ai].x, y: b[ai].y });\n          if(ai === best.edgeIdx) break;\n          ai = (ai + 1) % n;\n        }\n        polyA.push({ x: best.x, y: best.y });\n        var polyB = [{ x: best.x, y: best.y }];\n        var bj = (best.edgeIdx + 1) % n;\n        for(var step2=0; step2<n + 2; step2++){\n          polyB.push({ x: b[bj].x, y: b[bj].y });\n          if(bj === ci) break;\n          bj = (bj + 1) % n;\n        }\n        if(polyA.length < 3 ? true : polyB.length < 3) continue;\n        var aA = ringArea(polyA), aB = ringArea(polyB);\n        if(aA < minA ? true : aB < minA) continue;\n        \/\/ Score: prefer the ray that gives the most BALANCED split\n        \/\/ (smaller half \u2265 25 % of total area). This tends to find the\n        \/\/ cut that runs through the centre of the polygon rather than\n        \/\/ chipping off a tiny corner.\n        var balance = Math.min(aA, aB) \/ (aA + aB);\n        if(bestCut === null ? true : balance > bestCut.balance) bestCut = {\n          polyA: polyA,\n          polyB: polyB,\n          balance: balance,\n          \/\/ Endpoints of the chord \u2014 reflex vertex `v` and intersection\n          \/\/ point `best` on the opposite edge. The chord is an interior\n          \/\/ line of the original polygon by construction, so its\n          \/\/ midpoint is guaranteed to sit inside the original polygon.\n          cutX1: v.x, cutY1: v.y,\n          cutX2: best.x, cutY2: best.y\n        };\n      }\n      if(!bestCut) return null;\n      \/\/ Orientation-gain gate: only ACCEPT the split if the two sub-\n      \/\/ blocks would be driven at MEANINGFULLY DIFFERENT angles. If\n      \/\/ both halves have similar PCA axes (say a thin L-shape with\n      \/\/ both arms horizontal), splitting just adds an extra transit,\n      \/\/ extra turnarounds at the cut edge, and concentrated wheel\n      \/\/ compaction there \u2014 for zero coverage \/ orientation gain.\n      \/\/ Threshold: angles must differ by \u2265 25\u00b0 (mod 180, since AB\n      \/\/ headings are bidirectional). Below that, treat the polygon as\n      \/\/ a single block. Rule \u00a76 \u2014 \"split blocks only when truly\n      \/\/ necessary; more blocks = more compaction + turnarounds\".\n      var axCA = fieldAxis(bestCut.polyA);\n      var axCB = fieldAxis(bestCut.polyB);\n      var dCA = Math.atan2(axCA.uy, axCA.ux) * 180 \/ Math.PI;\n      var dCB = Math.atan2(axCB.uy, axCB.ux) * 180 \/ Math.PI;\n      while(dCA < 0) dCA += 180;\n      while(dCA >= 180) dCA -= 180;\n      while(dCB < 0) dCB += 180;\n      while(dCB >= 180) dCB -= 180;\n      var angleDiff = Math.abs(dCA - dCB);\n      if(angleDiff > 90) angleDiff = 180 - angleDiff;\n      if(angleDiff < 25) return null;\n      \/\/ Attach cutEdge metadata so the inter-block transport hop knows\n      \/\/ to route via the cut's MIDPOINT (always interior to the original\n      \/\/ polygon). Without this metadata the transport falls back to a\n      \/\/ straight diagonal hop end-to-end, which on concave shapes (L,\n      \/\/ T, U) cuts across the OUTSIDE of the field \u2014 breaking rule \u00a76\n      \/\/ \"the machine NEVER leaves the field boundary\".\n      var cutEdgeRec = { x1: bestCut.cutX1, y1: bestCut.cutY1, x2: bestCut.cutX2, y2: bestCut.cutY2 };\n      bestCut.polyA.cutEdge = cutEdgeRec;\n      bestCut.polyB.cutEdge = cutEdgeRec;\n      return [bestCut.polyA, bestCut.polyB];\n    }\n    \/\/ BFS \u2014 process blocks until no strong reflex remains OR maxBlocks hit.\n    var queue = [poly];\n    var done = [];\n    while(queue.length > 0){\n      if(done.length + queue.length >= maxN){\n        \/\/ Hit cap \u2014 emit remaining as-is\n        while(queue.length > 0) done.push(queue.shift());\n        break;\n      }\n      var blk = queue.shift();\n      var ri = strongestReflex(blk);\n      if(ri < 0){ done.push(blk); continue; }\n      var split = splitAtReflex(blk, ri);\n      if(!split){ done.push(blk); continue; }\n      queue.push(split[0]);\n      queue.push(split[1]);\n    }\n    return done;\n  }\n  function decomposeIntoConvex(poly){\n    function winding(b){\n      var s = 0;\n      for(var i=0; i<b.length; i++){\n        var j = (i + 1) % b.length;\n        s += b[i].x * b[j].y - b[j].x * b[i].y;\n      }\n      return s;\n    }\n    function concaveVertices(b){\n      var n = b.length;\n      var w = winding(b);\n      var ccw = w > 0;\n      var hits = [];\n      for(var i=0; i<n; i++){\n        var prev = b[(i - 1 + n) % n];\n        var curr = b[i];\n        var next = b[(i + 1) % n];\n        var ax = curr.x - prev.x, ay = curr.y - prev.y;\n        var bx = next.x - curr.x, by = next.y - curr.y;\n        var cross = ax * by - ay * bx;\n        if(ccw ? cross < -1e-6 : cross > 1e-6) hits.push(i);\n      }\n      return hits;\n    }\n    function rayHitEdge(ox, oy, dx, dy, ax, ay, bx, by){\n      var ex = bx - ax, ey = by - ay;\n      var denom = dx * ey - dy * ex;\n      if(Math.abs(denom) < 1e-9) return null;\n      var t = ((ax - ox) * ey - (ay - oy) * ex) \/ denom;\n      var u = ((ax - ox) * dy - (ay - oy) * dx) \/ denom;\n      if(t <= 1e-3) return null;\n      if(u < -1e-6 ? true : u > 1 + 1e-6) return null;\n      return { t: t, u: u, x: ox + dx * t, y: oy + dy * t };\n    }\n    function splitAtConcave(b, ci){\n      var n = b.length;\n      var v = b[ci];\n      var prev = b[(ci - 1 + n) % n];\n      var next = b[(ci + 1) % n];\n      \/\/ Two candidate split rays \u2014 extend each adjoining edge past v.\n      var d1x = v.x - prev.x, d1y = v.y - prev.y;\n      var L1 = Math.sqrt(d1x * d1x + d1y * d1y);\n      var d2x = v.x - next.x, d2y = v.y - next.y;\n      var L2 = Math.sqrt(d2x * d2x + d2y * d2y);\n      if(L1 < 1e-3 ? true : L2 < 1e-3) return null;\n      var rays = [\n        { dx: d1x \/ L1, dy: d1y \/ L1 },\n        { dx: d2x \/ L2, dy: d2y \/ L2 }\n      ];\n      for(var r=0; r<rays.length; r++){\n        var ray = rays[r];\n        \/\/ Find first polygon edge the ray hits, excluding edges adjacent\n        \/\/ to vertex ci (the two edges that meet AT v).\n        var best = null;\n        for(var i=0; i<n; i++){\n          if(i === ci) continue;\n          if(i === (ci - 1 + n) % n) continue;\n          var ea = b[i], eb = b[(i + 1) % n];\n          var hit = rayHitEdge(v.x, v.y, ray.dx, ray.dy, ea.x, ea.y, eb.x, eb.y);\n          if(!hit) continue;\n          if(best === null ? true : hit.t < best.t) best = { t: hit.t, x: hit.x, y: hit.y, edgeIdx: i };\n        }\n        if(!best) continue;\n        \/\/ Build two sub-polygons. Insert the hit point at best.edgeIdx's end.\n        \/\/ Sub-poly A: v, next, next+1, ..., best.edgeIdx, then hit\n        var polyA = [];\n        var i = ci;\n        for(var step=0; step<n + 2; step++){\n          polyA.push({ x: b[i].x, y: b[i].y });\n          if(i === best.edgeIdx) break;\n          i = (i + 1) % n;\n        }\n        polyA.push({ x: best.x, y: best.y });\n        \/\/ Sub-poly B: hit, then best.edgeIdx+1, ..., ci\n        var polyB = [{ x: best.x, y: best.y }];\n        var j = (best.edgeIdx + 1) % n;\n        for(var step2=0; step2<n + 2; step2++){\n          polyB.push({ x: b[j].x, y: b[j].y });\n          if(j === ci) break;\n          j = (j + 1) % n;\n        }\n        if(polyA.length < 3 ? true : polyB.length < 3) continue;\n        \/\/ Validate \u2014 both must be convex AND non-degenerate.\n        if(concaveVertices(polyA).length > 0 ? true : concaveVertices(polyB).length > 0) continue;\n        if(Math.abs(winding(polyA)) < 100 ? true : Math.abs(winding(polyB)) < 100) continue;\n        \/\/ Remove collinear vertices from each sub-polygon. After the\n        \/\/ cut, the concave vertex (and sometimes the cut-hit vertex)\n        \/\/ often sits on a straight edge between the surrounding\n        \/\/ vertices (e.g. L-shape's polyB has (440,60)\u2192(440,280)\u2192\n        \/\/ (440,540), all on x=440). roundPolygonCorners + the\n        \/\/ boundary-follow ring loop produce visible \"strange triangles\"\n        \/\/ on the right side of polyB if this redundant vertex is\n        \/\/ left in. Strip them.\n        function stripCollinear(poly){\n          var clean = [];\n          var n = poly.length;\n          for(var i=0; i<n; i++){\n            var prev = poly[(i - 1 + n) % n];\n            var curr = poly[i];\n            var next = poly[(i + 1) % n];\n            var ax = curr.x - prev.x, ay = curr.y - prev.y;\n            var bx = next.x - curr.x, by = next.y - curr.y;\n            var cross = ax * by - ay * bx;\n            var dotAB = ax * bx + ay * by;\n            \/\/ If cross \u2248 0 AND directions point the same way, the\n            \/\/ vertex is collinear and redundant.\n            if(Math.abs(cross) < 1e-3 ? dotAB > 0 : false) continue;\n            clean.push(curr);\n          }\n          return clean;\n        }\n        var polyAClean = stripCollinear(polyA);\n        var polyBClean = stripCollinear(polyB);\n        if(polyAClean.length < 3 ? true : polyBClean.length < 3) continue;\n        \/\/ Attach the cut-edge endpoints so the transport routing in\n        \/\/ generateLinesAll can keep the inter-part hop INSIDE the\n        \/\/ original polygon \u2014 the cut by construction sits along an\n        \/\/ interior chord, so its midpoint is always inside.\n        var cut = { x1: v.x, y1: v.y, x2: best.x, y2: best.y };\n        polyAClean.cutEdge = cut;\n        polyBClean.cutEdge = cut;\n        return [polyAClean, polyBClean];\n      }\n      return null;\n    }\n    \/\/ BFS decomposition with an iteration cap so a pathological input\n    \/\/ can't loop forever. Polygons with zero concave vertices land in\n    \/\/ `output` unchanged. Polygons we fail to split also land unchanged\n    \/\/ \u2014 Boundary Follow still works, it just won't tile both arms\n    \/\/ independently.\n    if(!poly ? true : poly.length < 4) return [poly];\n    var queue = [poly];\n    var output = [];\n    var iter = 0;\n    while(queue.length > 0 ? iter < 50 : false){\n      iter++;\n      var p = queue.shift();\n      var concaves = concaveVertices(p);\n      if(concaves.length === 0){ output.push(p); continue; }\n      var split = splitAtConcave(p, concaves[0]);\n      if(split){ queue.push(split[0]); queue.push(split[1]); }\n      else { output.push(p); }\n    }\n    while(queue.length > 0) output.push(queue.shift());\n    return output;\n  }\n  \/\/ Simplify a polygon by merging consecutive vertices closer than\n  \/\/ mergeDistM and removing near-collinear vertices (perpendicular\n  \/\/ distance < perpTolM from the line between neighbours). Dense\n  \/\/ boundaries (uploaded GIS data with 50+ vertices per few hundred\n  \/\/ metres) survive ONE inward offset before two adjacent ring vertices\n  \/\/ collapse to < 1 m apart, killing the boundary-follow loop on the\n  \/\/ minEdge guard. Simplifying both the input boundary and each offset\n  \/\/ ring keeps the ring loop alive deep into the field interior.\n  \/\/ Winding is preserved.\n  function simplifyPolygon(poly, mergeDistM, perpTolM){\n    if(!poly ? true : poly.length < 4) return poly;\n    var md = mergeDistM > 0 ? mergeDistM : 0;\n    var pt = perpTolM > 0 ? perpTolM : 0;\n    \/\/ Pass 1: merge close adjacent vertices.\n    var merged = [];\n    for(var i=0; i<poly.length; i++){\n      var p = poly[i];\n      if(merged.length > 0){\n        var q = merged[merged.length - 1];\n        var dxM = p.x - q.x, dyM = p.y - q.y;\n        if(dxM * dxM + dyM * dyM < md * md) continue;\n      }\n      merged.push({ x: p.x, y: p.y });\n    }\n    \/\/ Wrap-around: drop last if it's close to first.\n    if(merged.length >= 4){\n      var fM = merged[0];\n      var lM = merged[merged.length - 1];\n      var dxW = fM.x - lM.x, dyW = fM.y - lM.y;\n      if(dxW * dxW + dyW * dyW < md * md) merged.pop();\n    }\n    if(merged.length < 4) return poly;\n    \/\/ Pass 2: strip near-collinear vertices (perpendicular distance\n    \/\/ from the chord prev\u2192next below tolerance). Single forward pass\n    \/\/ is sufficient \u2014 collinear chains collapse one vertex at a time.\n    var clean = [];\n    var nM = merged.length;\n    for(var k=0; k<nM; k++){\n      var prev = clean.length > 0 ? clean[clean.length - 1] : merged[(k - 1 + nM) % nM];\n      var curr = merged[k];\n      var next = merged[(k + 1) % nM];\n      var ax = next.x - prev.x, ay = next.y - prev.y;\n      var aLen = Math.sqrt(ax * ax + ay * ay);\n      if(aLen < 1e-9){ clean.push(curr); continue; }\n      var px = curr.x - prev.x, py = curr.y - prev.y;\n      var perp = Math.abs(px * ay - py * ax) \/ aLen;\n      var dotAB = px * ax + py * ay;\n      \/\/ Only drop the vertex when it sits BETWEEN prev and next on the\n      \/\/ same line (dotAB > 0 and projection < aLen\u00b2) \u2014 guards against\n      \/\/ dropping a sharp spike whose perpendicular happens to be small.\n      if(perp < pt ? dotAB > 0 ? dotAB < aLen * aLen : false : false) continue;\n      clean.push(curr);\n    }\n    \/\/ Runaway-collapse guard: the running-chord collinear pass keeps\n    \/\/ growing the prev\u2192next chord as it drops vertices, so on a\n    \/\/ slightly-curved boundary (lv closed loop 287 verts, 27.8 ha)\n    \/\/ each next vertex looks \"collinear\" with the now-long chord \u2192\n    \/\/ 287 \u2192 7 vertices, area 27.8 ha \u2192 0.9 ha, boundary shrinks\n    \/\/ catastrophically and misaligns from the lines that share the\n    \/\/ same geographic source (reported 2026-06-03). If the simplify\n    \/\/ dropped more than \u00bd the input, reject and return the merge-only\n    \/\/ result (still safe \u2014 duplicate-vertex merge alone can't collapse\n    \/\/ shape).\n    if(clean.length < merged.length * 0.5) return merged;\n    return clean.length >= 4 ? clean : merged;\n  }\n  function offsetPolygonInward(b, distM, minAreaRatio){\n    var n = b.length;\n    var shoelace = 0;\n    for(var i=0; i<n; i++){\n      var j = (i + 1) % n;\n      shoelace += b[i].x * b[j].y - b[j].x * b[i].y;\n    }\n    var winding = shoelace > 0 ? 1 : -1;\n    var sx = new Array(n), sy = new Array(n);\n    for(var v=0; v<n; v++){\n      var pIdx = (v - 1 + n) % n;\n      var nIdx = (v + 1) % n;\n      var e1x = b[v].x - b[pIdx].x;\n      var e1y = b[v].y - b[pIdx].y;\n      var e2x = b[nIdx].x - b[v].x;\n      var e2y = b[nIdx].y - b[v].y;\n      var l1 = Math.sqrt(e1x*e1x + e1y*e1y);\n      var l2 = Math.sqrt(e2x*e2x + e2y*e2y);\n      if(l1 < 1e-9 ? true : l2 < 1e-9){ sx[v] = b[v].x; sy[v] = b[v].y; continue; }\n      var n1x = -e1y \/ l1 * winding, n1y = e1x \/ l1 * winding;\n      var n2x = -e2y \/ l2 * winding, n2y = e2x \/ l2 * winding;\n      var bxv = n1x + n2x, byv = n1y + n2y;\n      var bl = Math.sqrt(bxv*bxv + byv*byv);\n      if(bl < 1e-6){ sx[v] = b[v].x + n1x * distM; sy[v] = b[v].y + n1y * distM; continue; }\n      var cosFull = n1x * n2x + n1y * n2y;\n      \/\/ Correct Minkowski offset: vertex displacement along the bisector\n      \/\/ must be distM \/ sin(\u03b8\/2) where \u03b8 is the INTERIOR angle, so the\n      \/\/ resulting new edges sit at exact perpendicular distance distM\n      \/\/ from the original edges. cosFull = n1\u00b7n2 = cos(\u03b1), where \u03b1 is\n      \/\/ the angle between inward normals = 180\u00b0 \u2212 \u03b8. So\n      \/\/ sin(\u03b8\/2) = cos(\u03b1\/2) = \u221a((1+cosFull)\/2). Previously the code\n      \/\/ used \u221a((1\u2212cosFull)\/2) = cos(\u03b8\/2), which is the same value only\n      \/\/ for sharp 90\u00b0 corners \u2014 on obtuse corners (Hex 120\u00b0, Pivot\n      \/\/ 167\u00b0, curved uploaded boundaries) it OVER-displaced the\n      \/\/ vertices, pushing rings to ~\u221a3\u00d7 the requested distance on Hex\n      \/\/ and ~2\u00d7 on Pivot. That made the headland strip visibly twice\n      \/\/ as wide as the user asked for.\n      var cosHalf = Math.sqrt((1 + cosFull) * 0.5);\n      \/\/ Miter cap at cosHalf \u2265 0.5 (max 2\u00d7 offset distance along\n      \/\/ bisector). The cap now triggers on SHARP corners (cosHalf\n      \/\/ small \u21d4 \u03b8 small \u21d4 near-acute corner) where a true Minkowski\n      \/\/ offset would spike toward infinity \u2014 kept at the same 2\u00d7\n      \/\/ ceiling to avoid runaway displacement.\n      if(cosHalf < 0.5) cosHalf = 0.5;\n      sx[v] = b[v].x + (bxv \/ bl) * (distM \/ cosHalf);\n      sy[v] = b[v].y + (byv \/ bl) * (distM \/ cosHalf);\n    }\n    var kept = [];\n    for(var k=0; k<n; k++){\n      var pk = (k - 1 + n) % n;\n      var nk = (k + 1) % n;\n      var e1xC = b[k].x - b[pk].x, e1yC = b[k].y - b[pk].y;\n      var c1 = (e1xC * (sy[k] - b[pk].y) - e1yC * (sx[k] - b[pk].x)) * winding;\n      if(c1 < 0) continue;\n      var e2xC = b[nk].x - b[k].x, e2yC = b[nk].y - b[k].y;\n      var c2 = (e2xC * (sy[k] - b[k].y) - e2yC * (sx[k] - b[k].x)) * winding;\n      if(c2 < 0) continue;\n      kept.push({ x: sx[k], y: sy[k] });\n    }\n    if(kept.length < 4) return null;\n    var origArea = 0;\n    for(var oi=0; oi<n; oi++){\n      var oj = (oi + 1) % n;\n      origArea += b[oi].x * b[oj].y - b[oj].x * b[oi].y;\n    }\n    origArea = Math.abs(origArea) * 0.5;\n    var newArea = 0;\n    for(var ki=0; ki<kept.length; ki++){\n      var kj = (ki + 1) % kept.length;\n      newArea += kept[ki].x * kept[kj].y - kept[kj].x * kept[ki].y;\n    }\n    newArea = Math.abs(newArea) * 0.5;\n    var arMin = typeof minAreaRatio === 'number' ? minAreaRatio : 0.25;\n    if(newArea < origArea * arMin) return null;\n    \/\/ Rule \u00a76 \u2014 the inward-offset polygon CAN self-intersect at sharp\n    \/\/ tips or deep concave notches (lv Landgut bottom-left at 36 m\n    \/\/ headland, reported 2026-06-04). Repair it here so every consumer\n    \/\/ (interior, pullBack, ring offsets) gets a clean polygon without\n    \/\/ having to remember to call repairSelfIntersections themselves.\n    if(typeof polygonSelfIntersects === 'function' ? polygonSelfIntersects(kept) : false){\n      var repaired = typeof repairSelfIntersections === 'function' ? repairSelfIntersections(kept) : null;\n      if(repaired ? repaired.length >= 4 : false){\n        \/\/ Re-check area floor \u2014 repair can shed too much area if the\n        \/\/ self-crossing is the dominant feature.\n        var repArea = 0;\n        for(var rri=0; rri<repaired.length; rri++){\n          var rrj = (rri + 1) % repaired.length;\n          repArea += repaired[rri].x * repaired[rrj].y - repaired[rrj].x * repaired[rri].y;\n        }\n        repArea = Math.abs(repArea) * 0.5;\n        if(repArea >= origArea * arMin) return repaired;\n      }\n      return null;  \/\/ self-intersecting + unrepairable \u2192 fall back to caller\n    }\n    return kept;\n  }\n  \/\/ Replace sharp convex corners of a polygon with arc fillets at the\n  \/\/ equipment's minimum turn radius. Real ag machines cannot pivot at a\n  \/\/ sharp polygon vertex \u2014 they trace an arc tangent to the two adjoining\n  \/\/ edges. Applied to headland rings + boundary-follow rings so the\n  \/\/ drive path through each polygon vertex respects the machine's\n  \/\/ physical constraint. Concave (interior > 180\u00b0) corners are left as-is:\n  \/\/ those are inward dents the offset already handled. Nearly-straight\n  \/\/ corners (turn angle < 15\u00b0) keep their original vertex \u2014 no fillet\n  \/\/ needed.\n  function roundPolygonCorners(poly, radius){\n    if(!poly ? true : poly.length < 3 ? true : radius <= 0) return poly;\n    var n = poly.length;\n    \/\/ Winding direction \u2014 determines which side is \"interior\" for choosing\n    \/\/ arc-center direction.\n    var shoelace = 0;\n    for(var si=0; si<n; si++){\n      var sj = (si + 1) % n;\n      shoelace += poly[si].x * poly[sj].y - poly[sj].x * poly[si].y;\n    }\n    var ccw = shoelace > 0;\n    var out = [];\n    for(var i=0; i<n; i++){\n      var prev = poly[(i - 1 + n) % n];\n      var curr = poly[i];\n      var next = poly[(i + 1) % n];\n      var inX = curr.x - prev.x, inY = curr.y - prev.y;\n      var inL = Math.sqrt(inX*inX + inY*inY);\n      var outX = next.x - curr.x, outY = next.y - curr.y;\n      var outL = Math.sqrt(outX*outX + outY*outY);\n      if(inL < 1e-6 ? true : outL < 1e-6){ out.push({ x: curr.x, y: curr.y }); continue; }\n      var inUx = inX \/ inL, inUy = inY \/ inL;\n      var outUx = outX \/ outL, outUy = outY \/ outL;\n      \/\/ Interior angle at curr: angle between (-in) and out directions\n      var cosInner = -inUx * outUx + -inUy * outUy;\n      if(cosInner > 1) cosInner = 1; else if(cosInner < -1) cosInner = -1;\n      var interiorAngle = Math.acos(cosInner);\n      var turnAngle = Math.PI - interiorAngle;\n      if(turnAngle < Math.PI \/ 12){\n        out.push({ x: curr.x, y: curr.y });\n        continue;\n      }\n      \/\/ Convex vs concave detection via 2D cross product of incoming + outgoing.\n      \/\/ For CCW winding, convex corners have cross > 0; for CW, cross < 0.\n      var cross = inUx * outUy - inUy * outUx;\n      var isConvex = ccw ? (cross > 0) : (cross < 0);\n      if(!isConvex){\n        out.push({ x: curr.x, y: curr.y });\n        continue;\n      }\n      var halfInner = interiorAngle * 0.5;\n      \/\/ Tangent distance from vertex (where the arc touches each edge).\n      var tanDist = radius \/ Math.tan(halfInner);\n      \/\/ Cap so the fillet can't eat more than half of either edge.\n      var maxTan = Math.min(inL, outL) * 0.45;\n      if(tanDist > maxTan) tanDist = maxTan;\n      var actualR = tanDist * Math.tan(halfInner);\n      \/\/ Tangent points on each edge.\n      var t1x = curr.x - inUx * tanDist, t1y = curr.y - inUy * tanDist;\n      var t2x = curr.x + outUx * tanDist, t2y = curr.y + outUy * tanDist;\n      \/\/ Arc center sits on the interior bisector at distance r\/sin(\u03b8\/2).\n      var bisX = -inUx + outUx, bisY = -inUy + outUy;\n      var bisL = Math.sqrt(bisX*bisX + bisY*bisY);\n      if(bisL < 1e-6){ out.push({ x: curr.x, y: curr.y }); continue; }\n      var bisUx = bisX \/ bisL, bisUy = bisY \/ bisL;\n      var centerDist = actualR \/ Math.sin(halfInner);\n      var cx = curr.x + bisUx * centerDist;\n      var cy = curr.y + bisUy * centerDist;\n      var ang1 = Math.atan2(t1y - cy, t1x - cx);\n      var ang2 = Math.atan2(t2y - cy, t2x - cx);\n      var arcSpan = ang2 - ang1;\n      if(arcSpan > Math.PI) arcSpan -= 2 * Math.PI;\n      if(arcSpan < -Math.PI) arcSpan += 2 * Math.PI;\n      var nSamp = Math.max(4, Math.floor(Math.abs(arcSpan) * 8 \/ Math.PI));\n      out.push({ x: t1x, y: t1y });\n      for(var k=1; k<nSamp; k++){\n        var tk = k \/ nSamp;\n        var ak = ang1 + arcSpan * tk;\n        out.push({ x: cx + Math.cos(ak) * actualR, y: cy + Math.sin(ak) * actualR });\n      }\n      out.push({ x: t2x, y: t2y });\n    }\n    return out;\n  }\n  \/\/ Point-in-polygon (ray casting). Polygon is array of {x, y}, not necessarily closed.\n  function pointInPoly(x, y, poly){\n    var inside = false;\n    var n = poly.length;\n    for(var i=0, j=n-1; i<n; j=i++){\n      var xi = poly[i].x, yi = poly[i].y;\n      var xj = poly[j].x, yj = poly[j].y;\n      var intersect = ((yi > y) !== (yj > y)) ? (x < (xj - xi) * (y - yi) \/ (yj - yi + 1e-12) + xi) : false;\n      if(intersect) inside = !inside;\n    }\n    return inside;\n  }\n  \/\/ Closest point on the polygon's boundary to (x, y). Returns {x, y}\n  \/\/ \u2014 the projection onto whichever edge is nearest. Used by the\n  \/\/ single-polygon decomposition transport routing to snap any sample\n  \/\/ that falls outside the original polygon back to its nearest edge,\n  \/\/ so the visible transport line hugs the field boundary instead of\n  \/\/ cutting through a concave notch.\n  function nearestPointOnPolygonBoundary(x, y, poly){\n    var n = poly ? poly.length : 0;\n    if(n < 2) return null;\n    var bestD = Infinity, bestX = poly[0].x, bestY = poly[0].y;\n    for(var i=0; i<n; i++){\n      var j = (i + 1) % n;\n      var ax = poly[i].x, ay = poly[i].y;\n      var bx = poly[j].x, by = poly[j].y;\n      var dx = bx - ax, dy = by - ay;\n      var L2 = dx * dx + dy * dy;\n      if(L2 < 1e-9) continue;\n      var t = ((x - ax) * dx + (y - ay) * dy) \/ L2;\n      if(t < 0) t = 0; else if(t > 1) t = 1;\n      var px = ax + dx * t, py = ay + dy * t;\n      var d = (x - px) * (x - px) + (y - py) * (y - py);\n      if(d < bestD){ bestD = d; bestX = px; bestY = py; }\n    }\n    return { x: bestX, y: bestY };\n  }\n  \/\/ All arc sample points must lie inside `poly`. Used to reject arcs that leave\n  \/\/ the drivable area.\n  function arcInsidePoly(arc, poly){\n    if(!arc ? true : arc.length < 2) return false;\n    for(var i=0; i<arc.length; i++){\n      if(!pointInPoly(arc[i].x, arc[i].y, poly)) return false;\n    }\n    return true;\n  }\n  \/\/ Middle 80% of arc samples must NOT lie inside `interior` (the worked zone).\n  \/\/ Endpoints sit ON the interior boundary by construction so we skip the first\n  \/\/ and last 10% of samples.\n  function arcAvoidsInterior(arc, interior){\n    if(!arc ? true : !interior) return true;\n    if(arc.length < 4) return true;\n    \/\/ Arc endpoints sit ON the interior boundary by construction. Floating-point\n    \/\/ jitter near the start\/end may classify them as just-inside, so skip 15%\n    \/\/ at each end. Middle 70% still has to avoid the worked zone.\n    var skip = Math.max(1, Math.floor(arc.length * 0.15));\n    for(var i=skip; i<arc.length-skip; i++){\n      if(pointInPoly(arc[i].x, arc[i].y, interior)) return false;\n    }\n    return true;\n  }\n  \/\/ \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n  \/\/ OBSTACLES \/ HOLES (rule \u00a76 edge-type taxonomy \u2192 `obstacle_boundary`).\n  \/\/ A field part can carry `.holes` \u2014 an array of inner rings (ponds,\n  \/\/ woodlots, poles, no-work zones). Real client fields routinely have\n  \/\/ them (one imported field had 9). Body passes, U-turn arcs, transports,\n  \/\/ swath, and coverage must all treat hole interiors as NOT drivable +\n  \/\/ NOT countable. Holes are buffered outward by ~wM\/2 (an obstacle\n  \/\/ headland) so the implement keeps clearance.\n  \/\/ \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n  \/\/ True if (x, y) is inside ANY of the given hole rings.\n  function pointInHoles(x, y, holes){\n    if(!holes ? true : holes.length === 0) return false;\n    for(var h=0; h<holes.length; h++){\n      if(pointInPoly(x, y, holes[h])) return true;\n    }\n    return false;\n  }\n  \/\/ Inside the field PART = inside its outer ring AND outside every hole.\n  function pointInFieldPart(x, y, part){\n    if(!pointInPoly(x, y, part)) return false;\n    if(part.holes ? part.holes.length > 0 : false){\n      if(pointInHoles(x, y, part.holes)) return false;\n    }\n    return true;\n  }\n  \/\/ Subtract hole interiors from a list of clipped segments. A pass that\n  \/\/ crosses a hole splits into the before-hole + after-hole sub-segments;\n  \/\/ the operator lifts + drives around the obstacle. `holes` are the\n  \/\/ already-buffered rings (expanded by the obstacle clearance).\n  function subtractHolesFromSegs(segs, holes){\n    if(!holes ? true : holes.length === 0) return segs;\n    var out = [];\n    for(var s=0; s<segs.length; s++){\n      var seg = segs[s];\n      var x0 = seg.x0, y0 = seg.y0, x1 = seg.x1, y1 = seg.y1;\n      var dx = x1 - x0, dy = y1 - y0;\n      \/\/ Collect parametric cut points where the segment crosses any hole edge.\n      var cuts = [0, 1];\n      for(var h=0; h<holes.length; h++){\n        var ring = holes[h];\n        var nb = ring.length;\n        for(var i=0; i<nb; i++){\n          var j = (i + 1) % nb;\n          var ax = ring[i].x, ay = ring[i].y;\n          var ex = ring[j].x - ax, ey = ring[j].y - ay;\n          var denom = dy * ex - dx * ey;\n          if(Math.abs(denom) < 1e-9) continue;\n          var u = (dx * (ay - y0) - dy * (ax - x0)) \/ denom;\n          var t = (ex * (ay - y0) - ey * (ax - x0)) \/ denom;\n          if(u < -1e-9 ? true : u > 1 + 1e-9) continue;\n          if(t < -1e-9 ? true : t > 1 + 1e-9) continue;\n          cuts.push(t < 0 ? 0 : (t > 1 ? 1 : t));\n        }\n      }\n      cuts.sort(function(a, b){ return a - b; });\n      \/\/ De-dup near-equal cut params.\n      var clean = [cuts[0]];\n      for(var k=1; k<cuts.length; k++){\n        if(cuts[k] - clean[clean.length-1] > 1e-6) clean.push(cuts[k]);\n      }\n      \/\/ Keep sub-segments whose midpoint is OUTSIDE every hole.\n      for(var p=0; p+1<clean.length; p++){\n        var t0 = clean[p], t1 = clean[p+1];\n        if(t1 - t0 < 1e-6) continue;\n        var midT = (t0 + t1) * 0.5;\n        var mx = x0 + dx * midT, my = y0 + dy * midT;\n        if(pointInHoles(mx, my, holes)) continue;\n        out.push({ x0: x0 + dx * t0, y0: y0 + dy * t0, x1: x0 + dx * t1, y1: y0 + dy * t1 });\n      }\n    }\n    return out;\n  }\n  \/\/ DIRECTIONAL hole buffer for straight passes along a known axis\n  \/\/ (2026-06-11 \u2014 \"let's try to cover 100%\"). The implement is a wM-wide\n  \/\/ bar PERPENDICULAR to travel, so the forbidden centreline region\n  \/\/ around an obstacle is the hole dilated by wM\/2 LATERALLY but only\n  \/\/ ~1 m LONGITUDINALLY: driving straight at an obstacle, the machine\n  \/\/ legitimately works right up to its edge (the bar ahead is what\n  \/\/ hits, and that happens at ~0 m, not wM\/2). The previous isotropic\n  \/\/ wM\/2 buffer cut every pass a half-implement short on BOTH sides of\n  \/\/ every hole \u2014 18 m \u00d7 swath-width notches that summed to ~2 ha of\n  \/\/ \"missed area\" on a real client field with 6 obstacles at 36 m.\n  \/\/ Built as the convex hull of the hole shifted \u00b1(perp \u00d7 wM\/2) and\n  \/\/ \u00b1(along \u00d7 1 m) \u2014 exact Minkowski sum for convex holes, slightly\n  \/\/ conservative (over-blocking, the safe direction) for concave ones.\n  \/\/ Isotropic buffers stay in force for U-turn arcs + ring splits +\n  \/\/ wave-displacement guards (a TURNING machine sweeps all directions).\n  function dirHoleBuffer(hole, ux, uy, wM){\n    var pvx = -uy * (wM * 0.5), pvy = ux * (wM * 0.5);\n    var avx = ux * 1.0, avy = uy * 1.0;\n    var pts = [];\n    for(var i=0; i<hole.length; i++){\n      var h = hole[i];\n      pts.push({ x: h.x + pvx + avx, y: h.y + pvy + avy });\n      pts.push({ x: h.x + pvx - avx, y: h.y + pvy - avy });\n      pts.push({ x: h.x - pvx + avx, y: h.y - pvy + avy });\n      pts.push({ x: h.x - pvx - avx, y: h.y - pvy - avy });\n    }\n    var hull = convexHull(pts);\n    return (hull ? hull.length >= 3 : false) ? hull : hole;\n  }\n  \/\/ Split a closed ring polyline into OPEN sub-arcs that avoid holes.\n  \/\/ A Boundary-Follow concentric ring can't be a single closed loop when\n  \/\/ an obstacle sits on its path \u2014 so we drop samples inside any\n  \/\/ (buffered) hole and emit the surviving contiguous runs as separate\n  \/\/ arcs. The operator drives each arc, lifts at the obstacle, and\n  \/\/ resumes on the far side. Returns [] when the whole ring is consumed.\n  function splitRingAroundHoles(samples, holes){\n    if(!holes ? true : holes.length === 0) return [samples];\n    var runs = [];\n    var cur = [];\n    for(var i=0; i<samples.length; i++){\n      var s = samples[i];\n      if(pointInHoles(s.x, s.y, holes)){\n        if(cur.length >= 2) runs.push(cur);\n        cur = [];\n      } else {\n        cur.push(s);\n      }\n    }\n    if(cur.length >= 2){\n      \/\/ If the ring started AND ended outside holes, the first + last\n      \/\/ runs are the same physical arc wrapping the seam \u2014 join them.\n      if(runs.length > 0 ? !pointInHoles(samples[0].x, samples[0].y, holes) : false){\n        runs[0] = cur.concat(runs[0]);\n      } else {\n        runs.push(cur);\n      }\n    }\n    return runs;\n  }\n  \/\/ True if no point of the arc\/polyline falls inside any hole \u2014 used to\n  \/\/ reject U-turn arcs + transports that would cut through an obstacle.\n  function arcAvoidsHoles(arc, holes){\n    if(!arc ? true : (holes ? holes.length === 0 : true)) return true;\n    for(var i=0; i<arc.length; i++){\n      if(pointInHoles(arc[i].x, arc[i].y, holes)) return false;\n    }\n    return true;\n  }\n  \/\/ Partition a flat list of rings into OUTER parts (each carrying its own\n  \/\/ `.holes`). A ring fully contained inside a larger ring is a hole of\n  \/\/ its SMALLEST container. Parser-agnostic \u2014 works for shapefile inner\n  \/\/ rings, GeoJSON polygon holes, and KML inner boundaries alike, since\n  \/\/ all three encode a hole as \"a ring inside another ring\". Returns the\n  \/\/ outer parts only (with holes attached); the holes are removed from\n  \/\/ the top-level list so they're never treated as separate fields.\n  function assignHoles(rings){\n    if(!rings ? true : rings.length <= 1) return rings || [];\n    function ringArea(r){\n      var s = 0;\n      for(var i=0; i<r.length; i++){ var j=(i+1)%r.length; s += r[i].x*r[j].y - r[j].x*r[i].y; }\n      return Math.abs(s) * 0.5;\n    }\n    function ringCentroid(r){\n      var x=0, y=0; for(var i=0;i<r.length;i++){ x+=r[i].x; y+=r[i].y; } return { x: x\/r.length, y: y\/r.length };\n    }\n    var meta = rings.map(function(r){ return { ring: r, area: ringArea(r), cen: ringCentroid(r), container: -1 }; });\n    \/\/ For each ring, find the smallest-area OTHER ring that contains its centroid.\n    for(var i=0; i<meta.length; i++){\n      var best = -1, bestArea = Infinity;\n      for(var j=0; j<meta.length; j++){\n        if(i === j) continue;\n        if(meta[j].area <= meta[i].area) continue;  \/\/ container must be bigger\n        if(!pointInPoly(meta[i].cen.x, meta[i].cen.y, meta[j].ring)) continue;\n        if(meta[j].area < bestArea){ bestArea = meta[j].area; best = j; }\n      }\n      meta[i].container = best;\n    }\n    var outParts = [];\n    for(var k=0; k<meta.length; k++){\n      if(meta[k].container >= 0) continue;  \/\/ it's a hole \u2014 skipped here\n      var part = meta[k].ring;\n      var holes = [];\n      for(var m=0; m<meta.length; m++){\n        if(meta[m].container === k) holes.push(meta[m].ring);\n      }\n      if(holes.length > 0) part.holes = holes;\n      outParts.push(part);\n    }\n    return outParts.length > 0 ? outParts : rings;\n  }\n  \/\/ Build a candidate turn arc connecting (x1,y1) to (x2,y2) given style + radius\n  \/\/ + perpendicular direction (sign = +1 or -1 = which side to bulge).\n  \/\/ Returns array of {x, y} sample points (length ~24+) or null when geometry fails.\n  function buildArc(x1, y1, x2, y2, style, radiusM, sign){\n    var dx = x2 - x1, dy = y2 - y1;\n    var d = Math.sqrt(dx*dx + dy*dy);\n    if(d < 0.1) return null;\n    var ux = dx \/ d, uy = dy \/ d;\n    var perpX = -uy * sign, perpY = ux * sign;\n    var coords = [];\n    var nSeg = 24;\n    if(style === 'uturn'){\n      \/\/ Half-circle, radius = d\/2 (forced by geometry, not equipment).\n      var r = d * 0.5;\n      var mx = (x1 + x2) * 0.5, my = (y1 + y2) * 0.5;\n      \/\/ Angle from midpoint to p1, then sweep \u00b1180\u00b0 to p2.\n      var startAng = Math.atan2(y1 - my, x1 - mx);\n      var sweep = Math.PI * sign;\n      for(var i=0; i<=nSeg; i++){\n        var t = i \/ nSeg;\n        var ang = startAng + sweep * t;\n        coords.push({ x: mx + Math.cos(ang) * r, y: my + Math.sin(ang) * r });\n      }\n    } else if(style === 'racetrack'){\n      \/\/ Two 90\u00b0 arcs + straight leg. Radius capped at d\/2 \u00d7 0.95 so the leg stays > 0.\n      var r2 = Math.min(radiusM, d * 0.5 * 0.95);\n      if(r2 <= 0.01) return null;\n      \/\/ Sign convention mismatch fix: uturn bulges in the direction OPPOSITE\n      \/\/ to perpX\/perpY (its sweep direction is determined by `Math.PI * sign`\n      \/\/ around the chord midpoint, which puts the apex at +(uy, -ux)\u00b7r for\n      \/\/ sign=+1 \u2014 opposite of perpX\/perpY = (-uy, ux)\u00b7sign). To make\n      \/\/ racetrack bulge OUTWARD on the SAME side as uturn for the same sign,\n      \/\/ flip perp + sweep direction here. Without this flip, when buildTurnArc\n      \/\/ computed `preferSign = outwardSign(...)` (calibrated to uturn) and\n      \/\/ fell through to racetrack, the racetrack bulged INWARD into the\n      \/\/ worked interior \u2014 exactly the \"u-turns look inward\" symptom.\n      var rPerpX = uy * sign, rPerpY = -ux * sign;\n      \/\/ Circle centres perpendicular-out from each endpoint by r2.\n      var c1x = x1 + rPerpX * r2, c1y = y1 + rPerpY * r2;\n      var c2x = x2 + rPerpX * r2, c2y = y2 + rPerpY * r2;\n      var ang1 = Math.atan2(y1 - c1y, x1 - c1x);\n      var ang2End = Math.atan2(y2 - c2y, x2 - c2x);\n      var half = Math.max(8, Math.floor(nSeg \/ 3));\n      \/\/ Arc 1 around c1: from ang1 sweeping 90\u00b0 in the direction that swings\n      \/\/ AWAY from the chord (outward). With centres on the outward side, the\n      \/\/ outward-sweep direction is -sign (opposite of original sign-aligned\n      \/\/ perp).\n      var sweep1 = -Math.PI * 0.5 * sign;\n      for(var k=0; k<=half; k++){\n        var t1 = k \/ half;\n        var aa = ang1 + sweep1 * t1;\n        coords.push({ x: c1x + Math.cos(aa) * r2, y: c1y + Math.sin(aa) * r2 });\n      }\n      \/\/ Arc-1 end position\n      var endA1x = coords[coords.length-1].x, endA1y = coords[coords.length-1].y;\n      \/\/ Arc-2 start position (mirror of ang2End by +90\u00b0\u00b7sign reversed)\n      var sweep2start = ang2End - sweep1;\n      var startA2x = c2x + Math.cos(sweep2start) * r2;\n      var startA2y = c2y + Math.sin(sweep2start) * r2;\n      \/\/ Straight leg from endA1 to startA2 \u2014 sample 6 points so validation can\n      \/\/ detect if the leg crosses the field boundary.\n      var legSteps = 6;\n      for(var L=1; L<legSteps; L++){\n        var tt = L \/ legSteps;\n        coords.push({ x: endA1x + (startA2x - endA1x) * tt, y: endA1y + (startA2y - endA1y) * tt });\n      }\n      coords.push({ x: startA2x, y: startA2y });\n      \/\/ Arc 2 around c2: from sweep2start back to ang2End (sweep = +90\u00b0\u00b7sign)\n      for(var m=1; m<=half; m++){\n        var t2 = m \/ half;\n        var bb = sweep2start + sweep1 * t2;\n        coords.push({ x: c2x + Math.cos(bb) * r2, y: c2y + Math.sin(bb) * r2 });\n      }\n    } else if(style === 'flat'){\n      \/\/ Sinusoidal bulge, peak amplitude = min(radiusM, d\/4).\n      var amp = Math.min(radiusM, d * 0.25);\n      for(var f=0; f<=nSeg; f++){\n        var tf = f \/ nSeg;\n        var bx = x1 + dx * tf;\n        var by = y1 + dy * tf;\n        var bulge = Math.sin(Math.PI * tf) * amp;\n        coords.push({ x: bx + perpX * bulge, y: by + perpY * bulge });\n      }\n    } else {\n      return null;\n    }\n    return coords;\n  }\n  \/\/ Compute outward-bulge direction (sign +1 or -1) given p1, p2, and the\n  \/\/ interior centroid. The sign that pushes the arc APEX further from the\n  \/\/ centroid is \"outward\" \u2014 that's where the headland strip is.\n  \/\/\n  \/\/ IMPORTANT \u2014 for a U-turn arc built by buildArc(...) with sign s, the apex\n  \/\/ (at parametric t=0.5) is at chord-midpoint + radius\u00b7(uy\u00b7s, -ux\u00b7s) \u2014 NOT\n  \/\/ perpendicular = (-uy\u00b7s, ux\u00b7s). The buildArc apex math sweeps from the\n  \/\/ angle of vector(midpoint \u2192 p1) rotated by +\u03c0\/2\u00b7s, which negates the\n  \/\/ perpendicular relative to chord direction. Use (uy, -ux) here so the\n  \/\/ returned sign actually matches the arc's bulge direction.\n  function outwardSign(x1, y1, x2, y2, interiorCenterX, interiorCenterY, radiusM){\n    var midX = (x1 + x2) * 0.5;\n    var midY = (y1 + y2) * 0.5;\n    var dx = x2 - x1, dy = y2 - y1;\n    var d = Math.sqrt(dx*dx + dy*dy);\n    if(d < 1e-6) return 1;\n    var ux = dx \/ d, uy = dy \/ d;\n    \/\/ Apex offset at sign=+1: (uy, -ux) \u00b7 radius\n    var apexPx = midX + uy * radiusM;\n    var apexPy = midY - ux * radiusM;\n    var d1 = (apexPx - interiorCenterX) * (apexPx - interiorCenterX) + (apexPy - interiorCenterY) * (apexPy - interiorCenterY);\n    \/\/ Apex offset at sign=-1: (-uy, ux) \u00b7 radius\n    var apexMx = midX - uy * radiusM;\n    var apexMy = midY + ux * radiusM;\n    var d2 = (apexMx - interiorCenterX) * (apexMx - interiorCenterX) + (apexMy - interiorCenterY) * (apexMy - interiorCenterY);\n    return d1 >= d2 ? 1 : -1;\n  }\n  \/\/ Build a turn arc with the validation cascade described in RULES.md \u00a75.\n  \/\/ Returns { coords, style, radius, ok, reason } where coords is null on\n  \/\/ total failure. `ok=false` with a coords array means \"best-effort fallback,\n  \/\/ surface the reason to the user\".\n  \/\/\n  \/\/ passAxis (optional): {x, y} unit vector = pass-1's EXIT direction. When\n  \/\/ supplied, the turn aligns its chord PERPENDICULAR to passAxis so the\n  \/\/ arc's tangent at entry\/exit matches the actual pass direction. The \"rear\"\n  \/\/ endpoint gets a straight extension along passAxis to bring it forward\n  \/\/ to match the \"lead\" endpoint's along-axis position \u2014 this is what the\n  \/\/ operator actually does: drive past the pass end (implement lifted),\n  \/\/ pivot in the headland, drive back into the next pass. Without passAxis\n  \/\/ the legacy direct-chord arc is built (acceptable when endpoints already\n  \/\/ share the same along-axis position).\n  function buildTurnArc(p1, p2, drivable, interior, preferredStyle, turnRadiusM, halfImplementM, wM, passAxis, turnCtx){\n    \/\/ Compute alignment extension. For chord (p2-p1), decompose into the\n    \/\/ component along passAxis (call it `s`) and the perpendicular component.\n    \/\/ If |s| is significant, shift one endpoint forward along passAxis so the\n    \/\/ EFFECTIVE chord (between the shifted endpoints) is purely perpendicular\n    \/\/ to passAxis. The original endpoints are reconnected via straight legs\n    \/\/ prepended\/appended to the arc coords.\n    var prefixSeg = null;\n    var suffixSeg = null;\n    var effP1x = p1.x, effP1y = p1.y;\n    var effP2x = p2.x, effP2y = p2.y;\n    if(passAxis ? typeof passAxis.x === 'number' : false){\n      var s = (p2.x - p1.x) * passAxis.x + (p2.y - p1.y) * passAxis.y;\n      \/\/ Cap the extension so the straight prefix\/suffix leg never dwarfs\n      \/\/ the U-turn arc itself. When the misalignment exceeds ~0.6\u00d7wM the\n      \/\/ resulting \"long line + tiny half-circle\" looks unrealistic \u2014 the\n      \/\/ operator wouldn't drive that far past the pass end. Above the cap,\n      \/\/ accept the tangent kink at entry\/exit (direct tilted half-circle)\n      \/\/ rather than emit a giant straight extension. This trades a small\n      \/\/ visual kink for the U-turn shape that farmers actually recognise.\n      var sMax = wM * 0.6;\n      if(s > sMax ? true : s < -sMax){\n        s = 0;\n      }\n      if(s > 0.5){\n        \/\/ Pass-1 endpoint is \"behind\" \u2014 extend it forward to match pass-2's\n        \/\/ along-axis position.\n        effP1x = p1.x + s * passAxis.x;\n        effP1y = p1.y + s * passAxis.y;\n        prefixSeg = { fromX: p1.x, fromY: p1.y, toX: effP1x, toY: effP1y };\n      } else if(s < -0.5){\n        \/\/ Pass-2 endpoint is \"behind\" relative to pass-1 exit \u2014 extend it\n        \/\/ forward (= against pass-2 entry direction) to match pass-1's\n        \/\/ along-axis position.\n        effP2x = p2.x + (-s) * passAxis.x;\n        effP2y = p2.y + (-s) * passAxis.y;\n        suffixSeg = { fromX: effP2x, fromY: effP2y, toX: p2.x, toY: p2.y };\n      }\n    }\n    var dx = effP2x - effP1x, dy = effP2y - effP1y;\n    var d = Math.sqrt(dx*dx + dy*dy);\n    if(d < 0.1) return { coords: null, style: preferredStyle, radius: 0, ok: false, reason: 'pass endpoints coincide' };\n    \/\/ Cap chord length VERY generously \u2014 anything longer than ~6 swaths is so\n    \/\/ far that a synthetic turn arc would dwarf the field. For those (rare,\n    \/\/ happens only when boundary curves throw consecutive pass endpoints far\n    \/\/ apart) fall back to a traverse along the worked headland.\n    var maxReasonableChord = wM * 6;\n    if(d > maxReasonableChord){\n      return { coords: null, style: 'traverse', radius: 0, ok: true, reason: null };\n    }\n    \/\/ Outward reference centroid + safe polygon \u2014 hoisted out of this hot\n    \/\/ loop. If a turnCtx is supplied (always, when called from buildSerpentine)\n    \/\/ reuse the precomputed values. Fallback path retained for completeness\n    \/\/ \/ future direct callers.\n    var cx, cy, safe;\n    if(turnCtx){\n      cx = turnCtx.centroidX;\n      cy = turnCtx.centroidY;\n      safe = turnCtx.safe;\n    } else {\n      var ref = interior ? interior : drivable;\n      cx = 0; cy = 0;\n      for(var i=0; i<ref.length; i++){ cx += ref[i].x; cy += ref[i].y; }\n      cx \/= ref.length; cy \/= ref.length;\n      var sm = halfImplementM * 0.5;\n      safe = sm > 0 ? offsetPolygonInward(drivable, sm) : drivable;\n      if(!safe ? true : safe.length < 4) safe = drivable;\n    }\n    \/\/ Obstacle\/hole avoidance \u2014 a U-turn arc must never cut through a\n    \/\/ hole interior. Gated alongside the safe-polygon check at every\n    \/\/ candidate-accept site below.\n    var holesT = turnCtx ? turnCtx.holes : null;\n    \/\/ Build a stack of arc candidates from sharpest to softest. Order matters\n    \/\/ because the strict-then-relaxed loops below take the FIRST candidate\n    \/\/ that passes \u2014 we want the smallest arc that fits, not the largest.\n    \/\/\n    \/\/   uturn   = half-circle at radius = chord\/2 (the natural geometric fit\n    \/\/             for parallel pass spacing \u2014 typical case)\n    \/\/   racetrack = two 90\u00b0 arcs joined by a straight leg, at radius =\n    \/\/             turnRadiusM (the equipment's minimum). Used when chord >\n    \/\/             2\u00b7turnRadiusM (clean U-turn too big to fit headland)\n    \/\/             OR when chord < 2\u00b7turnRadiusM but we still want to show\n    \/\/             realistic motion instead of a sharp pivot (equipment-spec'd\n    \/\/             radius is sharper than the chord\/2 geometric default).\n    \/\/\n    \/\/ Replaces the old code path that returned style:'traverse' the moment\n    \/\/ chord\/2 < turnRadiusM \u2014 that path made the sprite teleport along a\n    \/\/ dashed line, looking like an \"unrealistic pivot\" (user feedback).\n    \/\/ safe polygon + outwardSign reference centroid were computed above (from\n    \/\/ turnCtx when called via buildSerpentine, or inline as a fallback). The\n    \/\/ \"Rule \u00a714\" intent is unchanged: the arc's full footprint must stay\n    \/\/ inside drivable inset by halfImpl \u00d7 0.5 so the equipment edge doesn't\n    \/\/ cross the boundary.\n    var preferSign = outwardSign(effP1x, effP1y, effP2x, effP2y, cx, cy, d * 0.5);\n    \/\/ Helper: stitch the prefix straight leg + arc samples + suffix straight\n    \/\/ leg into one continuous coord array. Each leg is sampled at 4 points so\n    \/\/ playback animates the straight portion smoothly. Returns null if any\n    \/\/ input is missing.\n    function withExtensions(arc){\n      if(!arc) return null;\n      var legSteps = 4;\n      var coords = [];\n      if(prefixSeg){\n        coords.push({ x: prefixSeg.fromX, y: prefixSeg.fromY });\n        for(var i=1; i<legSteps; i++){\n          var t = i \/ legSteps;\n          coords.push({\n            x: prefixSeg.fromX + (prefixSeg.toX - prefixSeg.fromX) * t,\n            y: prefixSeg.fromY + (prefixSeg.toY - prefixSeg.fromY) * t\n          });\n        }\n      }\n      for(var k=0; k<arc.length; k++){\n        coords.push({ x: arc[k].x, y: arc[k].y });\n      }\n      if(suffixSeg){\n        for(var j=1; j<legSteps; j++){\n          var t2 = j \/ legSteps;\n          coords.push({\n            x: suffixSeg.fromX + (suffixSeg.toX - suffixSeg.fromX) * t2,\n            y: suffixSeg.fromY + (suffixSeg.toY - suffixSeg.fromY) * t2\n          });\n        }\n        coords.push({ x: suffixSeg.toX, y: suffixSeg.toY });\n      }\n      return coords;\n    }\n    \/\/ Cascade order, restructured to honour CLAUDE.md rule \u00a76 \"minimise\n    \/\/ wheel-pass repeats\": every STRICT attempt (no dipping into the worked\n    \/\/ body interior) runs across ALL candidate \u00d7 extension combinations\n    \/\/ BEFORE any RELAXED attempt. Previously the code tried strict+uturn,\n    \/\/ then RELAXED uturn (which dips into worked ground), THEN strict\n    \/\/ racetrack \u2014 which meant a returnable non-dipping racetrack was masked\n    \/\/ by an earlier dipping uturn. Now a non-dipping arc of any shape wins\n    \/\/ over a dipping arc of any other shape.\n    \/\/\n    \/\/   Candidates: uturn (half-circle, chord\/2 radius) + racetrack (two 90\u00b0\n    \/\/               arcs + leg at turnRadiusM). Both are tried for both the\n    \/\/               extension-aligned chord AND the direct chord.\n    \/\/   Strict     = arc inside `safe` AND avoids worked interior (no dip).\n    \/\/   Relaxed    = arc inside `safe` only (interior dip allowed; we ALWAYS\n    \/\/                surface the \"U-turn dips\" warning so the user sees it).\n    var cands = [];\n    if(d * 0.5 >= turnRadiusM){\n      cands.push({ style: 'uturn', radius: d * 0.5 });\n    }\n    cands.push({ style: 'racetrack', radius: turnRadiusM });\n    var hasExtension = prefixSeg ? true : !!suffixSeg;\n    var directD = 0, directPreferSign = 0, directCands = null;\n    if(hasExtension){\n      var directDx = p2.x - p1.x, directDy = p2.y - p1.y;\n      directD = Math.sqrt(directDx*directDx + directDy*directDy);\n      if(directD > 0.1 ? directD <= maxReasonableChord : false){\n        directPreferSign = outwardSign(p1.x, p1.y, p2.x, p2.y, cx, cy, directD * 0.5);\n        directCands = [];\n        if(directD * 0.5 >= turnRadiusM){\n          directCands.push({ style: 'uturn', radius: directD * 0.5 });\n        }\n        directCands.push({ style: 'racetrack', radius: turnRadiusM });\n      }\n    }\n    function evalExtCand(cand){\n      var a = buildArc(effP1x, effP1y, effP2x, effP2y, cand.style, cand.radius, preferSign);\n      if(!a) return null;\n      return { coords: withExtensions(a), style: cand.style, radius: cand.radius };\n    }\n    function evalDirectCand(dc){\n      var a = buildArc(p1.x, p1.y, p2.x, p2.y, dc.style, dc.radius, directPreferSign);\n      if(!a) return null;\n      return { coords: a, style: dc.style, radius: dc.radius };\n    }\n    \/\/ PHASE 1 \u2014 STRICT. Iterate (extension+candidate, direct+candidate)\n    \/\/ returning the first that passes both validations. Preferring the\n    \/\/ extension form first because its chord is perpendicular to passes\n    \/\/ and gives a cleaner tangent at entry\/exit; if that fails, the direct\n    \/\/ chord version is still strict so worth trying before any relaxation.\n    for(var c1=0; c1<cands.length; c1++){\n      var r1 = evalExtCand(cands[c1]);\n      if(!r1) continue;\n      if(arcInsidePoly(r1.coords, safe) ? (arcAvoidsInterior(r1.coords, interior) ? arcAvoidsHoles(r1.coords, holesT) : false) : false){\n        return { coords: r1.coords, style: r1.style, radius: r1.radius, ok: true, reason: null };\n      }\n    }\n    if(directCands){\n      for(var dc1=0; dc1<directCands.length; dc1++){\n        var rd1 = evalDirectCand(directCands[dc1]);\n        if(!rd1) continue;\n        if(arcInsidePoly(rd1.coords, safe) ? (arcAvoidsInterior(rd1.coords, interior) ? arcAvoidsHoles(rd1.coords, holesT) : false) : false){\n          return { coords: rd1.coords, style: rd1.style, radius: rd1.radius, ok: true, reason: null };\n        }\n      }\n    }\n    \/\/ PHASE 2 \u2014 RELAXED. Same candidates, no interior-dip check. Returns\n    \/\/ ok:false so the renderer + UI can surface a \"tight headland\" warning.\n    \/\/ Hole avoidance is NOT relaxed \u2014 driving through an obstacle is never\n    \/\/ acceptable, unlike dipping into the worked headland strip.\n    for(var c2=0; c2<cands.length; c2++){\n      var r2 = evalExtCand(cands[c2]);\n      if(!r2) continue;\n      if(arcInsidePoly(r2.coords, safe) ? arcAvoidsHoles(r2.coords, holesT) : false){\n        return {\n          coords: r2.coords, style: r2.style, radius: r2.radius,\n          ok: false, reason: 'U-turn dips into worked zone \u2014 widen headland strip.'\n        };\n      }\n    }\n    if(directCands){\n      for(var dc2=0; dc2<directCands.length; dc2++){\n        var rd2 = evalDirectCand(directCands[dc2]);\n        if(!rd2) continue;\n        if(arcInsidePoly(rd2.coords, safe) ? arcAvoidsHoles(rd2.coords, holesT) : false){\n          return {\n            coords: rd2.coords, style: rd2.style, radius: rd2.radius,\n            ok: false, reason: 'U-turn dips into worked zone \u2014 widen headland strip.'\n          };\n        }\n      }\n    }\n    \/\/ Last resort \u2014 no outward arc fits inside the boundary. Rule \u00a714 says\n    \/\/ the machine must stay inside the field, so DO NOT emit an arc that\n    \/\/ would clearly leave. Return a traverse instead \u2014 the caller routes it\n    \/\/ along the headland ring. Operator sees a warning + a ring-routed path\n    \/\/ instead of a phantom arc exiting the field.\n    return {\n      coords: null, style: 'traverse', radius: 0,\n      ok: false, reason: 'No turn fits inside field boundary \u2014 routing along the headland ring instead.'\n    };\n  }\n  \/\/ Build the drivable polygon = boundary + outsideBufferM outward offset.\n  \/\/ For the lead-magnet MVP there are no obstacles, so this is just boundary\n  \/\/ dilation. With turnBuf=0, drivable === boundary.\n  function buildDrivable(b, outsideBufferM){\n    if(outsideBufferM <= 0) return b;\n    \/\/ Outward offset = inward offset with negated distance. Negate inward by\n    \/\/ flipping the winding sign convention: reuse offsetPolygonInward with\n    \/\/ negative distM \u2014 but the function clamps min vertices etc, so do manual.\n    var n = b.length;\n    var shoelace = 0;\n    for(var i=0; i<n; i++){\n      var j = (i + 1) % n;\n      shoelace += b[i].x * b[j].y - b[j].x * b[i].y;\n    }\n    var winding = shoelace > 0 ? 1 : -1;\n    var out = [];\n    for(var v=0; v<n; v++){\n      var pIdx = (v - 1 + n) % n;\n      var nIdx = (v + 1) % n;\n      var e1x = b[v].x - b[pIdx].x;\n      var e1y = b[v].y - b[pIdx].y;\n      var e2x = b[nIdx].x - b[v].x;\n      var e2y = b[nIdx].y - b[v].y;\n      var l1 = Math.sqrt(e1x*e1x + e1y*e1y);\n      var l2 = Math.sqrt(e2x*e2x + e2y*e2y);\n      if(l1 < 1e-9 ? true : l2 < 1e-9){ out.push({ x: b[v].x, y: b[v].y }); continue; }\n      \/\/ OUTWARD normal = -inward\n      var n1x = e1y \/ l1 * winding, n1y = -e1x \/ l1 * winding;\n      var n2x = e2y \/ l2 * winding, n2y = -e2x \/ l2 * winding;\n      var bxv = n1x + n2x, byv = n1y + n2y;\n      var bl = Math.sqrt(bxv*bxv + byv*byv);\n      if(bl < 1e-6){ out.push({ x: b[v].x + n1x * outsideBufferM, y: b[v].y + n1y * outsideBufferM }); continue; }\n      var cosFull = n1x * n2x + n1y * n2y;\n      var sinHalf = Math.sqrt((1 - cosFull) * 0.5);\n      if(sinHalf < 0.1) sinHalf = 0.1;\n      out.push({ x: b[v].x + (bxv \/ bl) * (outsideBufferM \/ sinHalf), y: b[v].y + (byv \/ bl) * (outsideBufferM \/ sinHalf) });\n    }\n    return out;\n  }\n  \/\/ Returns {x0, y0, x1, y1} oriented so coords[0] is the lower along-axis projection.\n  function orientSeg(seg, axis){\n    var p0Along = seg.x0 * axis.ux + seg.y0 * axis.uy;\n    var p1Along = seg.x1 * axis.ux + seg.y1 * axis.uy;\n    if(p0Along <= p1Along) return seg;\n    return { x0: seg.x1, y0: seg.y1, x1: seg.x0, y1: seg.y0, kind: seg.kind, samples: seg.samples };\n  }\n  \/\/ Rule \u00a714 helper \u2014 sample the straight chord between two points and check\n  \/\/ every sample is inside `poly`. Used to validate a traverse before\n  \/\/ accepting it; if false, the operator would leave the field boundary.\n  function traverseInsideBoundary(p1, p2, poly){\n    var N = 12;\n    for(var i=1; i<N; i++){\n      var t = i \/ N;\n      var x = p1.x + (p2.x - p1.x) * t;\n      var y = p1.y + (p2.y - p1.y) * t;\n      if(!pointInPoly(x, y, poly)) return false;\n    }\n    return true;\n  }\n  \/\/ Route from p1 to p2 along the headland-ring perimeter \u2014 the agronomically\n  \/\/ correct path when the straight chord would exit the boundary. Finds the\n  \/\/ closest ring vertex to each endpoint, walks the ring in the shorter\n  \/\/ direction. Returns the full vertex sequence [p1, ringVerts..., p2] or\n  \/\/ [p1, p2] if no ring is available.\n  \/\/ Walk a closed ring of vertices the SHORTER way between the points\n  \/\/ nearest p1 and p2. Returns [p1, ...ringVerts, p2]. Used both for the\n  \/\/ headland ring (routeAlongRing) and for detouring around an obstacle\n  \/\/ (routeAroundHole) \u2014 same geometry, different ring.\n  function walkRingBetween(p1, p2, ring){\n    if(!ring ? true : ring.length < 4) return [p1, p2];\n    var N = ring.length - (ring[0].x === ring[ring.length-1].x ? ring[0].y === ring[ring.length-1].y ? 1 : 0 : 0);\n    function nearestIdx(p){\n      var best = 0, bestD = Infinity;\n      for(var i=0; i<N; i++){\n        var dx = ring[i].x - p.x, dy = ring[i].y - p.y;\n        var d = dx*dx + dy*dy;\n        if(d < bestD){ bestD = d; best = i; }\n      }\n      return best;\n    }\n    var i1 = nearestIdx(p1), i2 = nearestIdx(p2);\n    if(i1 === i2) return [p1, ring[i1], p2];\n    function ringDist(from, to){\n      var d = 0, k = from;\n      while(k !== to){ var kn = (k + 1) % N; d += Math.hypot(ring[kn].x - ring[k].x, ring[kn].y - ring[k].y); k = kn; }\n      return d;\n    }\n    var fwd = ringDist(i1, i2), bwd = ringDist(i2, i1);\n    var path = [p1, ring[i1]];\n    if(fwd <= bwd){ var k = (i1 + 1) % N; while(k !== i2){ path.push(ring[k]); k = (k + 1) % N; } }\n    else { var k2 = (i1 - 1 + N) % N; while(k2 !== i2){ path.push(ring[k2]); k2 = (k2 - 1 + N) % N; } }\n    path.push(ring[i2]); path.push(p2);\n    return path;\n  }\n  \/\/ Detour around the obstacle a straight hop would cross \u2014 walk the\n  \/\/ buffered hole ring (NOT the field perimeter). Bounded by the hole's\n  \/\/ circumference, so a band split by a pond costs an around-the-pond\n  \/\/ move, not a lap of the whole field (reported 2026-06-15 \u2014 r038 drove\n  \/\/ 96 perimeter ring-routes \u2248 200 km because each hole-split band hop\n  \/\/ walked the field edge). Returns null if no hole is actually crossed.\n  function routeAroundHole(p1, p2, holeRings){\n    if(!holeRings ? true : holeRings.length === 0) return null;\n    var hit = -1;\n    for(var s=1; s<=10; s++){\n      var t = s \/ 11;\n      var mx = p1.x + (p2.x - p1.x) * t, my = p1.y + (p2.y - p1.y) * t;\n      for(var h=0; h<holeRings.length; h++){\n        if(pointInPoly(mx, my, holeRings[h])){ hit = h; break; }\n      }\n      if(hit >= 0) break;\n    }\n    if(hit < 0) return null;\n    return walkRingBetween(p1, p2, holeRings[hit]);\n  }\n  function routeAlongRing(p1, p2, ringPasses){\n    if(!ringPasses ? true : ringPasses.length === 0) return [p1, p2];\n    var ring = ringPasses[0].samples;\n    if(!ring ? true : ring.length < 4) return [p1, p2];\n    \/\/ Closed-loop: last sample == first sample; work with unique vertices only.\n    var N = ring.length - (ring[0].x === ring[ring.length-1].x ? ring[0].y === ring[ring.length-1].y ? 1 : 0 : 0);\n    function nearestIdx(p){\n      var best = 0, bestD = Infinity;\n      for(var i=0; i<N; i++){\n        var dx = ring[i].x - p.x, dy = ring[i].y - p.y;\n        var d = dx*dx + dy*dy;\n        if(d < bestD){ bestD = d; best = i; }\n      }\n      return best;\n    }\n    var i1 = nearestIdx(p1);\n    var i2 = nearestIdx(p2);\n    if(i1 === i2) return [p1, ring[i1], p2];\n    \/\/ Compute distance walking forward (i1 \u2192 i2) and backward (i1 \u2192 i2 the other way)\n    function ringDist(from, to){\n      var d = 0;\n      var k = from;\n      while(k !== to){\n        var kNext = (k + 1) % N;\n        var dx = ring[kNext].x - ring[k].x, dy = ring[kNext].y - ring[k].y;\n        d += Math.sqrt(dx*dx + dy*dy);\n        k = kNext;\n      }\n      return d;\n    }\n    var fwd = ringDist(i1, i2);\n    var bwd = ringDist(i2, i1);\n    var path = [p1, ring[i1]];\n    if(fwd <= bwd){\n      var k = (i1 + 1) % N;\n      while(k !== i2){ path.push(ring[k]); k = (k + 1) % N; }\n    } else {\n      var k2 = (i1 - 1 + N) % N;\n      while(k2 !== i2){ path.push(ring[k2]); k2 = (k2 - 1 + N) % N; }\n    }\n    path.push(ring[i2]);\n    path.push(p2);\n    return path;\n  }\n  \/\/ Build serpentine drive path: snake through passes, generate validated turn\n  \/\/ arcs between consecutive endpoints. Returns { driveCoords, turnArcs, warning }.\n  function buildSerpentine(passes, drivable, interior, axis, turnStyle, turnRadiusM, halfImplementM, wM, holes){\n    if(!passes ? true : passes.length === 0) return { driveCoords: [], turnArcs: [], warning: null };\n    \/\/ PERF \u2014 hoist the polygon offset + interior centroid out of buildTurnArc.\n    \/\/ Both depend only on (drivable, interior, halfImplementM), NOT on the\n    \/\/ per-arc endpoints. Computing them inside buildTurnArc made the offset\n    \/\/ run N times per layout (once per pass-pair = ~97 redundant calls on a\n    \/\/ typical sample field), which is the dominant cost during the axis\n    \/\/ sweep. Compute once here, pass into buildTurnArc.\n    var safeMargin = halfImplementM * 0.5;\n    var safe = safeMargin > 0 ? offsetPolygonInward(drivable, safeMargin) : drivable;\n    if(!safe ? true : safe.length < 4) safe = drivable;\n    var refForCentroid = interior ? interior : drivable;\n    var centroidX = 0, centroidY = 0;\n    for(var ci=0; ci<refForCentroid.length; ci++){\n      centroidX += refForCentroid[ci].x;\n      centroidY += refForCentroid[ci].y;\n    }\n    centroidX \/= refForCentroid.length;\n    centroidY \/= refForCentroid.length;\n    var turnCtx = { safe: safe, centroidX: centroidX, centroidY: centroidY, holes: holes ? holes : null };\n    \/\/ Separate headland-ring passes (rule \u00a713 \u2014 driven first as closed loops,\n    \/\/ no serpentine, no turn arcs) from body passes that go through serpentine.\n    var ringPasses = [];\n    var bodyPasses = [];\n    for(var ri=0; ri<passes.length; ri++){\n      if(passes[ri].kind === 'headland-ring') ringPasses.push(passes[ri]);\n      else bodyPasses.push(passes[ri]);\n    }\n    \/\/ Orient + sort BODY passes by along-axis position of midpoint\n    var orient = bodyPasses.map(function(p){\n      if(p.samples){\n        var first = p.samples[0], last = p.samples[p.samples.length - 1];\n        var a0 = first.x * axis.ux + first.y * axis.uy;\n        var a1 = last.x * axis.ux + last.y * axis.uy;\n        if(a0 <= a1) return { samples: p.samples };\n        var rev = p.samples.slice().reverse();\n        return { samples: rev };\n      }\n      return orientSeg(p, axis);\n    });\n    \/\/ Sort body passes by perpendicular coordinate PRIMARY, then by\n    \/\/ along-axis midpoint SECONDARY. Concave polygons (Weg's tongue,\n    \/\/ L-shape's notch) produce MULTIPLE sub-segments at the same perp\n    \/\/ value when the parallel pass crosses the polygon multiple times.\n    \/\/ Without the along-midpoint tiebreak, the post-clip order is\n    \/\/ arbitrary and the serpentine snake can leap between disconnected\n    \/\/ sub-segments \u2014 visible as a long diagonal transport leg crossing\n    \/\/ the entire field (reported on Weg, rule \u00a76 path-crossings spike).\n    function passMidpoint(p){\n      if(p.samples){\n        var fx = p.samples[0].x, fy = p.samples[0].y;\n        var lx = p.samples[p.samples.length - 1].x, ly = p.samples[p.samples.length - 1].y;\n        return { x: (fx + lx) * 0.5, y: (fy + ly) * 0.5 };\n      }\n      return { x: (p.x0 + p.x1) * 0.5, y: (p.y0 + p.y1) * 0.5 };\n    }\n    orient.sort(function(a, b){\n      var am = passMidpoint(a), bm = passMidpoint(b);\n      var ap = -am.x * axis.uy + am.y * axis.ux;\n      var bp = -bm.x * axis.uy + bm.y * axis.ux;\n      if(Math.abs(ap - bp) > 1e-6) return ap - bp;\n      \/\/ Same perp band \u2014 order by along-axis midpoint so adjacent\n      \/\/ sub-segments stay together.\n      var aa = am.x * axis.ux + am.y * axis.uy;\n      var ba = bm.x * axis.ux + bm.y * axis.uy;\n      return aa - ba;\n    });\n    \/\/ CLUSTER body passes by GENUINE spatial disconnection. Two passes\n    \/\/ belong to the same cluster when:\n    \/\/   \u2022 their perpendicular distance \u2264 3 \u00d7 wM (adjacent strips), AND\n    \/\/   \u2022 their along-axis ranges OVERLAP (the strip between them has\n    \/\/     at least some inside-polygon coverage somewhere).\n    \/\/\n    \/\/ The earlier check used midpoint-to-midpoint along distance, which\n    \/\/ wrongly split hourglass \/ waisted polygons into two clusters when\n    \/\/ adjacent passes had shifted midpoints (narrow waist pulls the\n    \/\/ midpoint inward). The snake then drove half the field, transported\n    \/\/ across, and drove the other half \u2014 leaving the waist unworked.\n    \/\/ Reported 2026-06-03 on real Ukrainian client fields.\n    \/\/\n    \/\/ The overlap test catches the legitimate disconnect case (Weg-type\n    \/\/ concave with a tongue: passes through the tongue have zero along-\n    \/\/ axis overlap with passes through the main body) without firing\n    \/\/ on continuous-but-waisted shapes.\n    function passAlongRange(p){\n      var a0, a1;\n      if(p.samples){\n        a0 = a1 = p.samples[0].x * axis.ux + p.samples[0].y * axis.uy;\n        for(var pi=1; pi<p.samples.length; pi++){\n          var v = p.samples[pi].x * axis.ux + p.samples[pi].y * axis.uy;\n          if(v < a0) a0 = v; if(v > a1) a1 = v;\n        }\n      } else {\n        var v0 = p.x0 * axis.ux + p.y0 * axis.uy;\n        var v1 = p.x1 * axis.ux + p.y1 * axis.uy;\n        a0 = Math.min(v0, v1); a1 = Math.max(v0, v1);\n      }\n      return { lo: a0, hi: a1 };\n    }\n    \/\/ Connected-components clustering via union-find. Two passes belong\n    \/\/ to the same cluster iff there's ANY chain of \"adjacent and\n    \/\/ overlapping\" passes between them. Adjacent = perpendicular gap\n    \/\/ \u2264 3 \u00d7 wM. Overlapping = along-axis ranges intersect (\u2212 wM\n    \/\/ tolerance for near-touching passes). The pairwise check is\n    \/\/ necessary because multi-segment passes at the same perp band\n    \/\/ (one pass clipping into left + right sub-segments through a\n    \/\/ concave notch) DON'T overlap each other, but BOTH overlap with\n    \/\/ the unclipped passes at the band above\/below \u2014 so they should\n    \/\/ belong to the same cluster. Consecutive-pair clustering missed\n    \/\/ this and split into N clusters, producing the \"drive left,\n    \/\/ skip middle, come back to right\" pattern reported by users\n    \/\/ on real client fields (2026-06-03).\n    var CLUSTER_PERP_GAP = wM * 3;\n    var n = orient.length;\n    var clusters;\n    if(n === 0){\n      clusters = [];\n    } else {\n      var passMid = new Array(n);\n      var passRng = new Array(n);\n      for(var pi=0; pi<n; pi++){\n        passMid[pi] = passMidpoint(orient[pi]);\n        passRng[pi] = passAlongRange(orient[pi]);\n      }\n      var parent = new Array(n);\n      for(var ui=0; ui<n; ui++) parent[ui] = ui;\n      function ufFind(x){\n        while(parent[x] !== x){ parent[x] = parent[parent[x]]; x = parent[x]; }\n        return x;\n      }\n      function ufUnion(a, b){\n        var ra = ufFind(a), rb = ufFind(b);\n        if(ra !== rb) parent[ra] = rb;\n      }\n      for(var ci=0; ci<n; ci++){\n        for(var cj=ci+1; cj<n; cj++){\n          var mi = passMid[ci], mj = passMid[cj];\n          var dPerp = Math.abs((-(mj.x - mi.x) * axis.uy + (mj.y - mi.y) * axis.ux));\n          if(dPerp > CLUSTER_PERP_GAP) continue;\n          var ri = passRng[ci], rj = passRng[cj];\n          var overlap = Math.min(ri.hi, rj.hi) - Math.max(ri.lo, rj.lo);\n          \/\/ Require MEANINGFUL overlap to cluster \u2014 the L-shape's top-arm\n          \/\/ last pass and vertical-leg first pass have along-axis ranges\n          \/\/ that share a thin sliver at the corner (~wM wide). Counting\n          \/\/ that sliver as \"same cluster\" forced the serpentine to emit\n          \/\/ a ring-route around half the perimeter (~850 m per transit)\n          \/\/ on every notch-crossing pass-to-pass transition. Reported\n          \/\/ 2026-06-04 \u2014 L-shape AB Straight total drive 36 km vs\n          \/\/ 13 km for auto-blocks. Strict threshold: overlap > 25 % of\n          \/\/ the SHORTER pass's range so genuinely connected bands still\n          \/\/ pass while concave-notch slivers split into separate\n          \/\/ clusters routed via an explicit transport leg.\n          var shorterLen = Math.min(ri.hi - ri.lo, rj.hi - rj.lo);\n          var meaningfulOverlap = overlap > Math.max(wM * 0.5, shorterLen * 0.25);\n          if(meaningfulOverlap) ufUnion(ci, cj);\n        }\n      }\n      var compMap = {};\n      for(var fi=0; fi<n; fi++){\n        var root = ufFind(fi);\n        if(!compMap[root]) compMap[root] = [];\n        compMap[root].push(orient[fi]);\n      }\n      clusters = [];\n      for(var k in compMap) clusters.push(compMap[k]);\n    }\n    \/\/ Order clusters by area centroid along-axis position so the\n    \/\/ operator works one region top-to-bottom before moving to the next.\n    clusters.sort(function(a, b){\n      var sumA = 0, sumB = 0;\n      for(var i=0; i<a.length; i++){ var m = passMidpoint(a[i]); sumA += m.x * axis.ux + m.y * axis.uy; }\n      for(var j=0; j<b.length; j++){ var n = passMidpoint(b[j]); sumB += n.x * axis.ux + n.y * axis.uy; }\n      return (sumA \/ a.length) - (sumB \/ b.length);\n    });\n    \/\/ Decompose each cluster into LANES (columns) before snaking. The\n    \/\/ perp-primary\/along-secondary sort assumes one full pass per band;\n    \/\/ on fields fragmented by holes (r038, 9 holes) or by disconnected\n    \/\/ parts at overlapping perp ranges (p064, 2 parts) a band clips into\n    \/\/ left + right sub-segments, and perp ordering drives band-left,\n    \/\/ band-right, (band+1)-left, (band+1)-right \u2014 jumping ACROSS the gap\n    \/\/ every band (~100 cross-field transports, ~200 km total, reported\n    \/\/ 2026-06-15).\n    \/\/\n    \/\/ A LANE is a maximal column of passes you can snake without crossing\n    \/\/ a gap: each pass joins the lane whose most-recent (highest-perp)\n    \/\/ pass it is perp-ADJACENT to AND along-OVERLAPS. Left-of-gap and\n    \/\/ right-of-gap sub-segments fall into separate lanes; the machine\n    \/\/ finishes one column, then transports once to the next. Passes\n    \/\/ stay PERP-SORTED within a lane, so the per-lane snake is monotonic\n    \/\/ in perp and never backtracks (pure nearest-neighbour did backtrack\n    \/\/ \u2014 it picked a short pass sitting behind the snake, producing a\n    \/\/ 0\u00b0 spike on the continuous weg field). On a clean convex field\n    \/\/ every pass overlaps the band below \u2192 ONE lane \u2192 the classic\n    \/\/ boustrophedon snake, no regression.\n    function lanePerp(p){ var m = passMidpoint(p); return -m.x * axis.uy + m.y * axis.ux; }\n    function laneOrderCluster(cl, allowSplit){\n      if(cl.length <= 1) return { order: cl.slice(), starts: cl.map(function(){ return true; }) };\n      if(!allowSplit){\n        \/\/ Not a fragmented field \u2014 keep the proven perp-sort order as ONE\n        \/\/ lane (index 0 = lane start, rest alternate). Identical to the\n        \/\/ pre-2026-06-15 boustrophedon, so clean single-polygon fields\n        \/\/ (weg's tongue, lshape's notch) don't regress.\n        var starts0 = cl.map(function(_, ix){ return ix === 0; });\n        return { order: cl.slice(), starts: starts0 };\n      }\n      \/\/ cl arrives perp-sorted (inherited from the global orient.sort).\n      var lanes = [];  \/\/ each: { passes:[], topPerp, topRng }\n      for(var li=0; li<cl.length; li++){\n        var p = cl[li];\n        var rng = passAlongRange(p);\n        var perp = lanePerp(p);\n        var bestLane = -1, bestOv = wM * 0.5;\n        for(var ln=0; ln<lanes.length; ln++){\n          var lane = lanes[ln];\n          if(Math.abs(perp - lane.topPerp) > CLUSTER_PERP_GAP) continue;\n          var ov = Math.min(rng.hi, lane.topRng.hi) - Math.max(rng.lo, lane.topRng.lo);\n          if(ov > bestOv){ bestOv = ov; bestLane = ln; }\n        }\n        if(bestLane >= 0){\n          lanes[bestLane].passes.push(p);\n          lanes[bestLane].topPerp = perp;\n          lanes[bestLane].topRng = rng;\n        } else {\n          lanes.push({ passes: [p], topPerp: perp, topRng: rng });\n        }\n      }\n      \/\/ Order lanes so each is spatially ADJACENT to the previous one, by\n      \/\/ nearest-neighbour chaining on lane centroids (rule \u00a76 finish one\n      \/\/ column then move to the NEAREST next column). A plain along-axis\n      \/\/ sort can leave consecutive columns perp-far-apart, so the\n      \/\/ inter-lane transport cuts a long diagonal across worked body\n      \/\/ passes (the grey diagonals + crossings on r038, 2026-06-15).\n      \/\/ Visiting the nearest unvisited lane keeps each transition short.\n      function laneCentroid(lane){\n        var sx = 0, sy = 0;\n        for(var i=0; i<lane.passes.length; i++){ var m = passMidpoint(lane.passes[i]); sx += m.x; sy += m.y; }\n        return { x: sx \/ lane.passes.length, y: sy \/ lane.passes.length };\n      }\n      var laneCents = lanes.map(laneCentroid);\n      \/\/ Seed at the lowest along-axis lane (operator starts at one end).\n      var seed = 0, seedA = Infinity;\n      for(var lc=0; lc<lanes.length; lc++){\n        var av = laneCents[lc].x * axis.ux + laneCents[lc].y * axis.uy;\n        if(av < seedA){ seedA = av; seed = lc; }\n      }\n      var laneOrder = [];\n      var laneUsed = lanes.map(function(){ return false; });\n      var curIdx = seed;\n      for(var step=0; step<lanes.length; step++){\n        laneOrder.push(lanes[curIdx]);\n        laneUsed[curIdx] = true;\n        var nextIdx = -1, nextD = Infinity;\n        for(var nl=0; nl<lanes.length; nl++){\n          if(laneUsed[nl]) continue;\n          var dx = laneCents[nl].x - laneCents[curIdx].x, dy = laneCents[nl].y - laneCents[curIdx].y;\n          var dd = dx*dx + dy*dy;\n          if(dd < nextD){ nextD = dd; nextIdx = nl; }\n        }\n        if(nextIdx < 0) break;\n        curIdx = nextIdx;\n      }\n      lanes = laneOrder;\n      var out = [];\n      var starts = [];\n      for(var lo=0; lo<lanes.length; lo++){\n        var lp = lanes[lo].passes;\n        for(var lq=0; lq<lp.length; lq++){ out.push(lp[lq]); starts.push(lq === 0); }\n      }\n      return { order: out, starts: starts };\n    }\n    \/\/ Flatten clusters back into a single ordered list; remember the\n    \/\/ cluster-boundary indices so the serpentine code knows where the\n    \/\/ inter-cluster transport hops are (those get long-route treatment),\n    \/\/ and the lane-boundary indices so the serpentine resets its snake\n    \/\/ parity (each lane snakes with strict alternation; greedy orientation\n    \/\/ only at lane starts handles the gap crossing).\n    var clusterBoundary = {};\n    var laneBoundary = {};\n    \/\/ Lane splitting + greedy lane-entry orientation only help when the\n    \/\/ field is genuinely fragmented (interior holes, or > 1 disconnected\n    \/\/ cluster). On a single connected polygon they add backtrack spikes\n    \/\/ in dense fill regions, so gate them off there (reported 2026-06-15).\n    var fragmented = (holes ? holes.length > 0 : false) ? true : clusters.length > 1;\n    orient = [];\n    for(var ck=0; ck<clusters.length; ck++){\n      var lr = laneOrderCluster(clusters[ck], fragmented);\n      var cl = lr.order;\n      for(var cj=0; cj<cl.length; cj++){\n        if(cj === 0 ? ck > 0 : false) clusterBoundary[orient.length] = true;\n        if(lr.starts[cj]) laneBoundary[orient.length] = true;\n        orient.push(cl[cj]);\n      }\n    }\n    var driveCoords = [];\n    var turnArcs = [];\n    var warning = null;\n    var orderedPasses = [];  \/\/ body passes in actual drive direction (post-snake)\n    \/\/ Rotate each headland ring's sample order so the ring's drive END\n    \/\/ lands near the natural body-pass entry. Without rotation, the\n    \/\/ ring starts\/ends at vertex 0 (chosen by offsetPolygonInward's\n    \/\/ internal ordering) which can sit on the opposite side of the\n    \/\/ field from orient[0].samples[0]. Result: a long cross-field\n    \/\/ ring \u2192 body transport (visible as a left-to-right violet line on\n    \/\/ Hex Topography, reported 2026-06-02). The ring is a closed loop,\n    \/\/ so the operator drives the same physical perimeter regardless of\n    \/\/ where we cut it \u2014 rotating the cut point keeps U-turn arc\n    \/\/ geometry intact (no serpentine direction flip) and the ring \u2192\n    \/\/ body jump shrinks to a short radial hop. MUST run BEFORE the\n    \/\/ ring-pushing loop below, otherwise the un-rotated samples land\n    \/\/ in driveCoords and the optimization has no effect.\n    if(ringPasses.length > 0 ? orient.length > 0 : false){\n      var bodyEntry = orient[0].samples\n        ? orient[0].samples[0]\n        : { x: orient[0].x0, y: orient[0].y0 };\n      for(var rpi=0; rpi<ringPasses.length; rpi++){\n        var ring = ringPasses[rpi];\n        if(!ring.samples ? true : ring.samples.length < 4) continue;\n        var n = ring.samples.length - 1;  \/\/ last sample is dup of first\n        var bestVi = 0, bestVd = Infinity;\n        for(var v=0; v<n; v++){\n          var dvx = ring.samples[v].x - bodyEntry.x;\n          var dvy = ring.samples[v].y - bodyEntry.y;\n          var dv = dvx * dvx + dvy * dvy;\n          if(dv < bestVd){ bestVd = dv; bestVi = v; }\n        }\n        if(bestVi > 0){\n          var head = ring.samples.slice(bestVi, n);\n          var tail = ring.samples.slice(0, bestVi);\n          \/\/ New loop: vertex bestVi \u2192 ... \u2192 vertex n-1 \u2192 vertex 0 \u2192 ...\n          \/\/   \u2192 vertex bestVi-1 \u2192 vertex bestVi (closing dup so ring\n          \/\/   ends where it began, near the body-pass entry).\n          var closingDup = { x: ring.samples[bestVi].x, y: ring.samples[bestVi].y };\n          ring.samples = head.concat(tail).concat([closingDup]);\n        }\n      }\n    }\n    \/\/ Headland ring(s) FIRST \u2014 closed-loop traversal, no serpentine, no arcs.\n    \/\/ Between consecutive rings, the machine has to physically drive a short\n    \/\/ radial step inward from ring i's last vertex to ring i+1's first\n    \/\/ vertex. Sample that transit as 8 interpolated points (so playback\n    \/\/ animates the move instead of teleporting) and ALSO push it as a\n    \/\/ turn-arc with kind='ring-jump' so it renders as a visible violet\n    \/\/ path. Per rule \u00a76 the operator never teleports.\n    for(var rg=0; rg<ringPasses.length; rg++){\n      var ringSamps = ringPasses[rg].samples;\n      if(rg > 0 ? ringSamps.length > 0 : false){\n        var prevTail = driveCoords.length > 0 ? driveCoords[driveCoords.length - 1] : null;\n        var thisHead = ringSamps[0];\n        if(prevTail){\n          var jumpCoords = [{ x: prevTail.x, y: prevTail.y }];\n          var nT = 8;\n          for(var ts=1; ts<=nT; ts++){\n            var tj = ts \/ nT;\n            var jx = prevTail.x + (thisHead.x - prevTail.x) * tj;\n            var jy = prevTail.y + (thisHead.y - prevTail.y) * tj;\n            jumpCoords.push({ x: jx, y: jy });\n            driveCoords.push({ x: jx, y: jy, transport: true });\n          }\n          turnArcs.push({ coords: jumpCoords, ok: true, kind: 'ring-jump' });\n        } else {\n          driveCoords.push({ x: thisHead.x, y: thisHead.y, transport: true });\n        }\n      }\n      for(var rs=0; rs<ringSamps.length; rs++) driveCoords.push(ringSamps[rs]);\n    }\n    \/\/ Ring \u2192 first body pass jump rendered as a ring-jump arc so the\n    \/\/ operator's radial move from the innermost ring to the first body\n    \/\/ pass start is visible. firstBodyStart is now the corner of the\n    \/\/ body-pass region chosen above (closest to the ring tail).\n    if(ringPasses.length > 0 ? orient.length > 0 : false){\n      var firstBody = orient[0];\n      var firstBodyStart = firstBody.samples\n        ? firstBody.samples[0]\n        : { x: firstBody.x0, y: firstBody.y0 };\n      var ringTail = driveCoords.length > 0 ? driveCoords[driveCoords.length - 1] : null;\n      if(ringTail){\n        var rbCoords = [{ x: ringTail.x, y: ringTail.y }];\n        var nRB = 8;\n        for(var rbi=1; rbi<=nRB; rbi++){\n          var rbt = rbi \/ nRB;\n          var rbx = ringTail.x + (firstBodyStart.x - ringTail.x) * rbt;\n          var rby = ringTail.y + (firstBodyStart.y - ringTail.y) * rbt;\n          rbCoords.push({ x: rbx, y: rby });\n          driveCoords.push({ x: rbx, y: rby, transport: true });\n        }\n        turnArcs.push({ coords: rbCoords, ok: true, kind: 'ring-jump' });\n      } else {\n        driveCoords.push({ x: firstBodyStart.x, y: firstBodyStart.y, transport: true });\n      }\n    }\n    \/\/ Body passes \u2014 serpentine with validated U-turn arcs between them.\n    \/\/ Orientation rule (rewritten 2026-06-15): WITHIN a lane the snake\n    \/\/ alternates strictly (perp-monotonic passes \u2192 clean boustrophedon,\n    \/\/ never backtracks), and at each LANE START the first pass is oriented\n    \/\/ GREEDILY toward the previous pass's end so the gap crossing between\n    \/\/ columns is short. Pure greedy-everywhere backtracked on continuous\n    \/\/ fields (it picked a short pass sitting behind the snake \u2192 0\u00b0 spike\n    \/\/ on weg); pure fixed `i % 2` jumped across gaps on fragmented fields\n    \/\/ (left\/right sub-segments desynced the parity \u2192 100+ km of transport\n    \/\/ on r038\/p064). The lane split + per-lane alternation gets both right.\n    function passEndpoints(pp){\n      if(pp.samples) return [pp.samples[0], pp.samples[pp.samples.length - 1]];\n      return [{ x: pp.x0, y: pp.y0 }, { x: pp.x1, y: pp.y1 }];\n    }\n    var prevEnd = driveCoords.length > 0 ? driveCoords[driveCoords.length - 1] : null;\n    var laneParity = false;\n    for(var i=0; i<orient.length; i++){\n      var p = orient[i];\n      var pEnds = passEndpoints(p);\n      var rev2;\n      if(laneBoundary[i] ? true : i === 0){\n        \/\/ Lane start \u2014 orient greedily toward where the last pass ended.\n        if(prevEnd){\n          var dStart0 = (pEnds[0].x - prevEnd.x) * (pEnds[0].x - prevEnd.x) + (pEnds[0].y - prevEnd.y) * (pEnds[0].y - prevEnd.y);\n          var dStart1 = (pEnds[1].x - prevEnd.x) * (pEnds[1].x - prevEnd.x) + (pEnds[1].y - prevEnd.y) * (pEnds[1].y - prevEnd.y);\n          rev2 = dStart1 < dStart0;\n          \/\/ SPIKE GUARD: entering at the nearest end is right for a column\n          \/\/ we then snake away from, but for a short isolated fill-lane the\n          \/\/ nearest end can sit BEHIND us, so we'd drive in and immediately\n          \/\/ reverse (a 0\u00b0 backtrack spike \u2014 weg\/boundary centre-fill,\n          \/\/ reported 2026-06-15). If the pass body would head back toward\n          \/\/ prevEnd (connector\u2192pass angle > ~120\u00b0), enter from the far end\n          \/\/ instead and drive away. Trades a slightly longer connector for\n          \/\/ no backtrack; the U-turn arc smooths the longer connector.\n          var sPt = rev2 ? pEnds[1] : pEnds[0];\n          var ePt = rev2 ? pEnds[0] : pEnds[1];\n          var cdx = sPt.x - prevEnd.x, cdy = sPt.y - prevEnd.y;\n          var pdx = ePt.x - sPt.x, pdy = ePt.y - sPt.y;\n          var clen = Math.sqrt(cdx*cdx + cdy*cdy), plen = Math.sqrt(pdx*pdx + pdy*pdy);\n          if(clen > 1e-6 ? plen > 1e-6 : false){\n            if((cdx*pdx + cdy*pdy) \/ (clen*plen) < -0.5) rev2 = !rev2;\n          }\n        } else {\n          rev2 = false;\n        }\n        laneParity = rev2;\n      } else {\n        \/\/ Inside a lane \u2014 strict alternation continues the snake.\n        laneParity = !laneParity;\n        rev2 = laneParity;\n      }\n      var first2, last2;\n      if(p.samples){\n        var samps = rev2 ? p.samples.slice().reverse() : p.samples;\n        for(var s=0; s<samps.length; s++) driveCoords.push(samps[s]);\n        first2 = samps[0]; last2 = samps[samps.length - 1];\n        orderedPasses.push({ samples: samps, kind: p.kind });\n      } else {\n        if(rev2){\n          driveCoords.push({ x: p.x1, y: p.y1 });\n          driveCoords.push({ x: p.x0, y: p.y0 });\n          first2 = { x: p.x1, y: p.y1 }; last2 = { x: p.x0, y: p.y0 };\n          orderedPasses.push({ x0: p.x1, y0: p.y1, x1: p.x0, y1: p.y0, kind: p.kind });\n        } else {\n          driveCoords.push({ x: p.x0, y: p.y0 });\n          driveCoords.push({ x: p.x1, y: p.y1 });\n          first2 = { x: p.x0, y: p.y0 }; last2 = { x: p.x1, y: p.y1 };\n          orderedPasses.push({ x0: p.x0, y0: p.y0, x1: p.x1, y1: p.y1, kind: p.kind });\n        }\n      }\n      prevEnd = last2;\n      if(turnStyle === 'none' ? false : (i + 1 < orient.length)){\n        var nextP = orient[i + 1];\n        \/\/ Predict pass i+1's start EXACTLY as the orientation logic above\n        \/\/ will choose it: a lane start orients greedily toward last2, an\n        \/\/ in-lane pass alternates (!laneParity). Matching it keeps the\n        \/\/ U-turn arc target on the real entry point.\n        var nEnds = passEndpoints(nextP);\n        var nextRev;\n        if(laneBoundary[i + 1]){\n          var nd0 = (nEnds[0].x - last2.x) * (nEnds[0].x - last2.x) + (nEnds[0].y - last2.y) * (nEnds[0].y - last2.y);\n          var nd1 = (nEnds[1].x - last2.x) * (nEnds[1].x - last2.x) + (nEnds[1].y - last2.y) * (nEnds[1].y - last2.y);\n          nextRev = nd1 < nd0;\n          \/\/ Same spike guard as the orientation logic above, so the arc\n          \/\/ target matches where pass i+1 will actually start.\n          var nsPt = nextRev ? nEnds[1] : nEnds[0];\n          var nePt = nextRev ? nEnds[0] : nEnds[1];\n          var ncdx = nsPt.x - last2.x, ncdy = nsPt.y - last2.y;\n          var npdx = nePt.x - nsPt.x, npdy = nePt.y - nsPt.y;\n          var nclen = Math.sqrt(ncdx*ncdx + ncdy*ncdy), nplen = Math.sqrt(npdx*npdx + npdy*npdy);\n          if(nclen > 1e-6 ? nplen > 1e-6 : false){\n            if((ncdx*npdx + ncdy*npdy) \/ (nclen*nplen) < -0.5) nextRev = !nextRev;\n          }\n        } else {\n          nextRev = !laneParity;\n        }\n        var nextStart = nextRev ? nEnds[1] : nEnds[0];\n        \/\/ pass-1 EXIT direction at last2. For straight body passes the exit\n        \/\/ direction is the pass axis with sign determined by serpentine\n        \/\/ orientation (rev2 flips it). For samples-based passes (AB-curve,\n        \/\/ contour-follow) the local tangent at the last sample is what the\n        \/\/ operator is steering when they reach last2 \u2014 derive it from the\n        \/\/ last two samples so the U-turn aligns with the actual heading.\n        var dirA = null;\n        if(p.samples ? samps.length >= 2 : false){\n          var sLast = samps[samps.length - 1];\n          var sPrev = samps[samps.length - 2];\n          var ddx = sLast.x - sPrev.x, ddy = sLast.y - sPrev.y;\n          var dLen = Math.sqrt(ddx*ddx + ddy*ddy);\n          if(dLen > 1e-6) dirA = { x: ddx \/ dLen, y: ddy \/ dLen };\n        } else if(!p.samples){\n          dirA = rev2\n            ? { x: -axis.ux, y: -axis.uy }\n            : { x:  axis.ux, y:  axis.uy };\n        }\n        var turn = buildTurnArc(last2, nextStart, drivable, interior, turnStyle, turnRadiusM, halfImplementM, wM, dirA, turnCtx);\n        \/\/ Inter-pass U-turn arcs: implement stays ENGAGED through the\n        \/\/ turn. Many real ag operations work continuously on headland\n        \/\/ turns (sprayers with section control, broadcast spreaders,\n        \/\/ wide tillage), and even for operations that lift, the visual\n        \/\/ swath wash on the turn arc reads as a continuous worked path\n        \/\/ rather than a broken-looking gap. The headland-ring swath\n        \/\/ already covers the same strip; painting twice just deepens\n        \/\/ the visual at the turn corner (no metric impact \u2014 overlap %\n        \/\/ is computed from passes, not drive coords).\n        \/\/\n        \/\/ `arc: true` (not transport) \u2014 drawSwath paints; rule \u00a76\n        \/\/ acute-angle test skips these vertices because the operator\n        \/\/ physically drives a smooth arc, and any sharp interior angle\n        \/\/ measured between consecutive discrete samples at the\n        \/\/ body\u2192arc tangent join is a sampling artifact, not real ag-\n        \/\/ drivability problem.\n        if(turn.coords ? turn.coords.length > 1 : false){\n          \/\/ Hard obstacle backstop \u2014 densely sample the accepted arc and,\n          \/\/ if it still grazes a hole (coarse arc sampling can step over\n          \/\/ a small obstacle between two arc vertices, or a degenerate\n          \/\/ buffer let it past the gate), drive it as a LIFTED transport\n          \/\/ instead of a worked arc. Guarantees rule \u00a76: no worked metre\n          \/\/ inside a no-go zone.\n          var arcInHole = false;\n          if(holes ? holes.length > 0 : false){\n            for(var ah=1; ah<turn.coords.length; ah++){\n              var aP = turn.coords[ah-1], bP = turn.coords[ah];\n              for(var asub=0; asub<=3; asub++){\n                var atf = asub \/ 3;\n                var amx = aP.x + (bP.x - aP.x) * atf;\n                var amy = aP.y + (bP.y - aP.y) * atf;\n                if(pointInHoles(amx, amy, holes)){ arcInHole = true; break; }\n              }\n              if(arcInHole) break;\n            }\n          }\n          var arcFlag = arcInHole ? true : false;  \/\/ true \u2192 transport (lifted)\n          for(var tc=1; tc<turn.coords.length; tc++){\n            if(arcFlag) driveCoords.push({ x: turn.coords[tc].x, y: turn.coords[tc].y, transport: true });\n            else driveCoords.push({ x: turn.coords[tc].x, y: turn.coords[tc].y, arc: true });\n          }\n          turnArcs.push({ coords: turn.coords, ok: turn.ok, kind: arcFlag ? 'transport' : turn.style, transport: arcFlag ? true : undefined });\n          if(!turn.ok ? !warning : false) warning = turn.reason;\n        } else if(turn.style === 'traverse'){\n          \/\/ Long-chord case OR last-resort no-arc-fits case: route the\n          \/\/ operator across the worked headland. Rule \u00a714 \u2014 the straight\n          \/\/ chord may exit the field boundary if it cuts across a concave\n          \/\/ dent (e.g. the L-shape's notch) or a curved boundary. Sample\n          \/\/ the chord; if any point is outside `drivable`, route along the\n          \/\/ headland ring instead. Otherwise use the straight chord.\n          \/\/ A straight chord is only OK if it stays inside the boundary\n          \/\/ AND clears every hole. Sample the chord against the holes;\n          \/\/ if it would cut an obstacle, fall back to the ring route.\n          var straightInside = traverseInsideBoundary(last2, nextStart, drivable);\n          var straightClearsHoles = true;\n          if(straightInside ? holes : false){\n            var nSampH = 12;\n            for(var sh=0; sh<=nSampH; sh++){\n              var th = sh \/ nSampH;\n              var hx = last2.x + (nextStart.x - last2.x) * th;\n              var hy = last2.y + (nextStart.y - last2.y) * th;\n              if(pointInHoles(hx, hy, holes)){ straightClearsHoles = false; break; }\n            }\n          }\n          \/\/ Hop classification (rewritten 2026-06-15 after p064\/r038 drove\n          \/\/ 110+ ring-routes \u2248 220 km of perimeter-walking on fields the\n          \/\/ gap-fill + hole-splits fragmented).\n          \/\/   \u2022 Straight stays inside + clears holes \u2192 drive it straight.\n          \/\/     A SHORT hop (\u2264 10\u00d7wM) is a normal headland traverse; a LONG\n          \/\/     one is a LIFTED transport (implement up, no swath, no rework\n          \/\/     \u2014 wheels cross worked ground once, which is what a real\n          \/\/     operator does to reach a far region). Either way it's\n          \/\/     bounded by the chord length, never the perimeter.\n          \/\/   \u2022 Straight blocked (exits boundary \/ crosses a hole) \u2192 THEN\n          \/\/     route along the worked headland ring (the only safe path),\n          \/\/     but if that ring-walk balloons past 3\u00d7 the direct chord\n          \/\/     it's not worth it \u2014 lift + drive the (boundary-clipped)\n          \/\/     direct line instead.\n          var chordLenT = Math.hypot(nextStart.x - last2.x, nextStart.y - last2.y);\n          var straightUsable = straightInside ? straightClearsHoles : false;\n          var traverseCoords, kindT;\n          \/\/ Helper: total length of a polyline.\n          function polyLen(arr){ var L=0; for(var q=1; q<arr.length; q++) L += Math.hypot(arr[q].x-arr[q-1].x, arr[q].y-arr[q-1].y); return L; }\n          \/\/ A detour (around a hole, or along the headland) is only worth\n          \/\/ driving when it's barely longer than the direct line \u2014 an\n          \/\/ ABSOLUTE extra-distance budget, NOT a multiple of the chord.\n          \/\/ Routing a LONG hop along the headland ring is tempting (it\n          \/\/ follows worked ground) but the ring walk CROSSES every body\n          \/\/ pass endpoint it passes and overlaps other ring walks \u2014 on\n          \/\/ r038 it took crossings 39 \u2192 225 (measured 2026-06-15). A\n          \/\/ straight LIFTED transport crosses far fewer centerlines, so a\n          \/\/ long hop that stays inside + clears holes drives straight\n          \/\/ (implement up, no swath, no rework; wheels cross worked ground\n          \/\/ once \u2014 what a real operator does to reach a far region).\n          \/\/ Bounded by the chord, never the perimeter.\n          var detourBudget = wM * 6;\n          if(straightUsable){\n            traverseCoords = [last2, nextStart];\n            kindT = chordLenT > wM * 10 ? 'transport' : 'traverse';\n          } else if(straightInside ? !straightClearsHoles : false){\n            \/\/ Blocked by an OBSTACLE \u2014 detour around that hole only.\n            var around = (holes ? routeAroundHole(last2, nextStart, holes) : null);\n            if(around ? (around.length >= 2 ? polyLen(around) <= chordLenT + detourBudget : false) : false){\n              traverseCoords = around; kindT = 'ring-route';\n            } else { traverseCoords = [last2, nextStart]; kindT = 'transport'; }\n          } else {\n            \/\/ Blocked by the BOUNDARY (concave notch \/ noisy edge) \u2014 route\n            \/\/ along the worked headland ring only if that walk is short.\n            var rr = routeAlongRing(last2, nextStart, ringPasses);\n            if(rr.length > 2 ? polyLen(rr) <= chordLenT + detourBudget : false){\n              traverseCoords = rr; kindT = 'ring-route';\n            } else {\n              traverseCoords = [last2, nextStart]; kindT = 'transport';\n            }\n          }\n          if(traverseCoords.length >= 2){\n            for(var tc2=1; tc2<traverseCoords.length; tc2++){\n              driveCoords.push({ x: traverseCoords[tc2].x, y: traverseCoords[tc2].y, transport: true });\n            }\n            turnArcs.push({ coords: traverseCoords, ok: true, kind: kindT });\n            if(turn.reason ? !warning : false) warning = turn.reason;\n          }\n        }\n      }\n    }\n    return { driveCoords: driveCoords, turnArcs: turnArcs, warning: warning, orderedPasses: orderedPasses };\n  }\n  \/\/ Generate guidance layout for one approach. Returns:\n  \/\/   { passes: [...], headlandPoly, interiorPoly, drivable, turnArcs, drivePath, warning }\n  \/\/ The smart pipeline:\n  \/\/   1. drivable = boundary + outsideBufferM outward\n  \/\/   2. interior = boundary inward-offset by headlandM (the worked zone)\n  \/\/   3. headland = boundary minus interior (the turnaround zone)\n  \/\/   4. Passes clipped to INTERIOR (not full boundary) \u2014 endpoints sit on the\n  \/\/      inner edge of the headland by construction\n  \/\/   5. Serpentine through passes, generating turn arcs that bulge OUTWARD\n  \/\/      into the headland strip via the validation cascade\n  \/\/ For 'boundary' approach: skip interior passes, generate only the headland-\n  \/\/ following ring(s) \u2014 useful for orchards \/ grasslands where the field is\n  \/\/ worked along its perimeter.\n  \/\/ Trace a single contour line at constant elevation using a predictor-\n  \/\/ corrector scheme. Each step:\n  \/\/   PREDICT \u2014 move stepM perpendicular to the local gradient.\n  \/\/   CORRECT \u2014 sample elevation at the new point, move along the gradient\n  \/\/             by ((targetZ - currentZ) \/ |gradient|) to restore the target\n  \/\/             elevation. Keeps the path on the contour even when the\n  \/\/             terrain function is non-linear (e.g. a circular hill on the\n  \/\/             pivot field would otherwise drift off-level by step 50).\n  \/\/ Walks in `dir` (\u00b11) until it exits `clipPoly` or hits maxSteps. Returns\n  \/\/ sample points.\n  function walkContour(start, dir, clipPoly, stepM, maxSteps){\n    var pts = [{ x: start.x, y: start.y }];\n    var eps = 0.5;\n    var targetZ = terrainAt(start.x, start.y);\n    var prevPx = null, prevPy = null;\n    var closeRadius = stepM * 1.5;\n    var closeRadiusSq = closeRadius * closeRadius;\n    for(var s=0; s<maxSteps; s++){\n      \/\/ Loop detection \u2014 for closed contours (e.g. a circle around a hill\n      \/\/ peak), the walker would otherwise spin forever. Stop once we've\n      \/\/ come back within stepM \u00d7 1.5 of the seed after at least 10 steps.\n      \/\/ pts._closed flag tells the caller this contour wrapped around.\n      if(s > 10){\n        var dxS = pts[pts.length-1].x - start.x;\n        var dyS = pts[pts.length-1].y - start.y;\n        if(dxS*dxS + dyS*dyS < closeRadiusSq){ pts.push({ x: start.x, y: start.y }); pts._closed = true; break; }\n      }\n      var last = pts[pts.length - 1];\n      \/\/ Numerical gradient via central differences\n      var gx = (terrainAt(last.x + eps, last.y) - terrainAt(last.x - eps, last.y)) \/ (2 * eps);\n      var gy = (terrainAt(last.x, last.y + eps) - terrainAt(last.x, last.y - eps)) \/ (2 * eps);\n      var gLen2 = gx*gx + gy*gy;\n      if(gLen2 < 1e-12) break;\n      var gLen = Math.sqrt(gLen2);\n      \/\/ Predictor: perpendicular to gradient (rotated 90\u00b0), normalized\n      var px = -gy \/ gLen * dir;\n      var py = gx \/ gLen * dir;\n      \/\/ Smooth direction so the curve doesn't ping-pong on noisy terrain\n      if(prevPx !== null){\n        px = px * 0.7 + prevPx * 0.3;\n        py = py * 0.7 + prevPy * 0.3;\n        var pl = Math.sqrt(px*px + py*py);\n        if(pl > 1e-6){ px \/= pl; py \/= pl; }\n      }\n      prevPx = px; prevPy = py;\n      var nx = last.x + px * stepM;\n      var ny = last.y + py * stepM;\n      \/\/ Corrector: nudge back to target elevation along the local gradient\n      var nz = terrainAt(nx, ny);\n      var dz = targetZ - nz;\n      var gMag2x = (terrainAt(nx + eps, ny) - terrainAt(nx - eps, ny)) \/ (2 * eps);\n      var gMag2y = (terrainAt(nx, ny + eps) - terrainAt(nx, ny - eps)) \/ (2 * eps);\n      var gMag2sq = gMag2x*gMag2x + gMag2y*gMag2y;\n      if(gMag2sq > 1e-12){\n        var corr = dz \/ gMag2sq;\n        nx += gMag2x * corr;\n        ny += gMag2y * corr;\n      }\n      if(!pointInPoly(nx, ny, clipPoly)) break;\n      pts.push({ x: nx, y: ny });\n    }\n    return pts;\n  }\n  \/\/ Build contour-following passes from the active terrain. Spacing = wM\n  \/\/ (perpendicular implement-width), so each pass's swath tiles the next.\n  \/\/ Returns array of { samples, kind: 'adaptive' } passes clipped to\n  \/\/ `clipPoly` (the interior polygon).\n  function buildContourPasses(wM, clipPoly){\n    \/\/ 1. Find elevation range across clipPoly\n    var stats = fieldStats(clipPoly);\n    var lo = Infinity, hi = -Infinity;\n    var sampleN = 24;  \/\/ sparse sweep to find lo\/hi\n    var dxS = (stats.maxX - stats.minX) \/ sampleN;\n    var dyS = (stats.maxY - stats.minY) \/ sampleN;\n    for(var iy=0; iy<sampleN; iy++){\n      for(var ix=0; ix<sampleN; ix++){\n        var cxS = stats.minX + (ix + 0.5) * dxS;\n        var cyS = stats.minY + (iy + 0.5) * dyS;\n        if(!pointInPoly(cxS, cyS, clipPoly)) continue;\n        var z = terrainAt(cxS, cyS);\n        if(z < lo) lo = z;\n        if(z > hi) hi = z;\n      }\n    }\n    if(hi - lo < 0.3) return [];  \/\/ essentially flat \u2014 no useful contours\n    \/\/ 2. Estimate typical gradient magnitude across the field. Sampling the\n    \/\/    centroid alone fails for fields where the centroid sits on a peak\n    \/\/    or saddle (e.g. pivot \u2014 round hill at the centre \u2192 \u2207=0 there).\n    \/\/    Take 9 samples on a 3\u00d73 grid, use the median magnitude.\n    var gradSamples = [];\n    for(var gxi=0; gxi<3; gxi++){\n      for(var gyi=0; gyi<3; gyi++){\n        var sx = stats.minX + (gxi + 1) * (stats.maxX - stats.minX) \/ 4;\n        var sy = stats.minY + (gyi + 1) * (stats.maxY - stats.minY) \/ 4;\n        if(!pointInPoly(sx, sy, clipPoly)) continue;\n        var gxS = (terrainAt(sx + 1, sy) - terrainAt(sx - 1, sy)) * 0.5;\n        var gyS = (terrainAt(sx, sy + 1) - terrainAt(sx, sy - 1)) * 0.5;\n        gradSamples.push(Math.sqrt(gxS*gxS + gyS*gyS));\n      }\n    }\n    if(gradSamples.length === 0) return [];\n    gradSamples.sort(function(a, b){ return a - b; });\n    \/\/ 33rd-percentile gradient (was median = 50th). Spatial spacing between\n    \/\/ adjacent contour lines = elevStep \/ local_gradient. With the median\n    \/\/ gradient, ANY point with a below-median gradient ends up further\n    \/\/ than wM from its neighbours \u2014 visible as the wide diagonal gaps on\n    \/\/ Rect's diagonal-ridge terrain. The 33rd percentile shrinks elevStep\n    \/\/ ~30 %, so the lower-gradient half of the field is much closer to\n    \/\/ wM apart. Steep zones over-densify slightly; the existing dedupe\n    \/\/ pass drops any contour that doesn't add new coverage.\n    var pctIdx = Math.max(0, Math.floor(gradSamples.length * 0.33));\n    var gMag = gradSamples[pctIdx];\n    if(gMag < 1e-4) gMag = gradSamples[Math.floor(gradSamples.length \/ 2)];\n    if(gMag < 1e-4) return [];  \/\/ truly flat \u2192 no useful contours\n    var elevStep = wM * gMag;\n    \/\/ 3. Pick N levels evenly between lo+elevStep\/2 and hi-elevStep\/2\n    var nLevels = Math.max(2, Math.floor((hi - lo) \/ elevStep));\n    if(nLevels > 80) nLevels = 80;  \/\/ cap to avoid pathological terrain\n    var levels = [];\n    for(var L=0; L<nLevels; L++){\n      levels.push(lo + (L + 0.5) * (hi - lo) \/ nLevels);\n    }\n    \/\/ 4. For each level, find a seed point inside clipPoly with terrain \u2248 level\n    \/\/    then walk the contour in both directions, joining into one pass.\n    var passes = [];\n    var seedGrid = 32;\n    var sdx = (stats.maxX - stats.minX) \/ seedGrid;\n    var sdy = (stats.maxY - stats.minY) \/ seedGrid;\n    \/\/ Pre-sample on a finer grid for seeds\n    var seedZ = new Float32Array(seedGrid * seedGrid);\n    var seedIn = new Uint8Array(seedGrid * seedGrid);\n    for(var iy2=0; iy2<seedGrid; iy2++){\n      for(var ix2=0; ix2<seedGrid; ix2++){\n        var cx2 = stats.minX + (ix2 + 0.5) * sdx;\n        var cy2 = stats.minY + (iy2 + 0.5) * sdy;\n        seedZ[iy2 * seedGrid + ix2] = terrainAt(cx2, cy2);\n        seedIn[iy2 * seedGrid + ix2] = pointInPoly(cx2, cy2, clipPoly) ? 1 : 0;\n      }\n    }\n    var stepM = Math.max(2, wM * 0.4);\n    var maxSteps = 800;\n    for(var li=0; li<levels.length; li++){\n      var lv = levels[li];\n      \/\/ Find the seed cell where seedZ is closest to lv (among in-field cells)\n      var bestIdx = -1, bestDiff = Infinity;\n      for(var k=0; k<seedZ.length; k++){\n        if(!seedIn[k]) continue;\n        var diff = Math.abs(seedZ[k] - lv);\n        if(diff < bestDiff){ bestDiff = diff; bestIdx = k; }\n      }\n      if(bestIdx < 0) continue;\n      var ixS = bestIdx % seedGrid;\n      var iyS = Math.floor(bestIdx \/ seedGrid);\n      var seed = { x: stats.minX + (ixS + 0.5) * sdx, y: stats.minY + (iyS + 0.5) * sdy };\n      \/\/ Walk forward first. If it returns a closed loop, the contour is\n      \/\/ closed (e.g. circular hill) \u2014 skip the backward walk (would just\n      \/\/ trace the same loop in reverse, doubling the path length).\n      var fwd = walkContour(seed, +1, clipPoly, stepM, maxSteps);\n      var samples;\n      if(fwd._closed){\n        samples = fwd;\n      } else {\n        var bwd = walkContour(seed, -1, clipPoly, stepM, maxSteps);\n        bwd.reverse();\n        samples = bwd.concat(fwd.slice(1));\n      }\n      if(samples.length >= 4) passes.push({ samples: samples, kind: 'adaptive' });\n    }\n    \/\/ Dedupe \u2014 in steep zones the marching-elevation step packs multiple\n    \/\/ contour passes within wM of each other, which renders as a tangled\n    \/\/ bundle of intersecting curves. Walk passes in order and KEEP a pass\n    \/\/ only when it adds genuinely new coverage. Same rasterise-test as\n    \/\/ buildFillPasses but applied during pass emission.\n    if(passes.length > 1){\n      var dStats = fieldStats(clipPoly);\n      var dCell = Math.max(2, wM \/ 3);\n      var dnx = Math.max(8, Math.ceil((dStats.maxX - dStats.minX) \/ dCell));\n      var dny = Math.max(8, Math.ceil((dStats.maxY - dStats.minY) \/ dCell));\n      var dCovered = new Uint8Array(dnx * dny);\n      var dHalf2 = (wM * 0.5) * (wM * 0.5);\n      var kept = [];\n      function markPassCoverage(p){\n        for(var si=1; si<p.samples.length; si++){\n          var sa = p.samples[si-1], sb = p.samples[si];\n          var sdx = sb.x - sa.x, sdy = sb.y - sa.y;\n          var segL = Math.sqrt(sdx*sdx + sdy*sdy);\n          var nSt = Math.max(1, Math.ceil(segL \/ dCell));\n          for(var st=0; st<=nSt; st++){\n            var tt = st \/ nSt;\n            var sxk = sa.x + sdx * tt;\n            var syk = sa.y + sdy * tt;\n            \/\/ Mark cells within wM\/2 of this sample\n            var ixc = Math.floor((sxk - dStats.minX) \/ dCell);\n            var iyc = Math.floor((syk - dStats.minY) \/ dCell);\n            var r = Math.ceil((wM * 0.5) \/ dCell);\n            for(var dy0=-r; dy0<=r; dy0++){\n              for(var dx0=-r; dx0<=r; dx0++){\n                var ix2 = ixc + dx0, iy2 = iyc + dy0;\n                if(ix2 < 0 ? true : iy2 < 0 ? true : ix2 >= dnx ? true : iy2 >= dny) continue;\n                var ccx = dStats.minX + (ix2 + 0.5) * dCell;\n                var ccy = dStats.minY + (iy2 + 0.5) * dCell;\n                var pdx = ccx - sxk, pdy = ccy - syk;\n                if(pdx*pdx + pdy*pdy <= dHalf2) dCovered[iy2 * dnx + ix2] = 1;\n              }\n            }\n          }\n        }\n      }\n      function passAddsNewCoverage(p){\n        \/\/ Sample the pass, count how many of its sample positions land in\n        \/\/ cells NOT yet covered. Keep the pass when \u226545 % of samples\n        \/\/ contribute new ground. Tuned to drop the tight contour bundles\n        \/\/ that form near ridges + peaks (visible as a tangle of crossing\n        \/\/ curves) while still keeping every distinct iso-elevation band\n        \/\/ across the wider, gentler part of the field.\n        var totalSamp = 0, newSamp = 0;\n        for(var si=0; si<p.samples.length; si++){\n          var sxk = p.samples[si].x, syk = p.samples[si].y;\n          var ixc = Math.floor((sxk - dStats.minX) \/ dCell);\n          var iyc = Math.floor((syk - dStats.minY) \/ dCell);\n          if(ixc < 0 ? true : iyc < 0 ? true : ixc >= dnx ? true : iyc >= dny) continue;\n          totalSamp++;\n          if(!dCovered[iyc * dnx + ixc]) newSamp++;\n        }\n        return totalSamp > 0 ? (newSamp \/ totalSamp) >= 0.30 : false;\n      }\n      for(var dp=0; dp<passes.length; dp++){\n        if(kept.length === 0 ? true : passAddsNewCoverage(passes[dp])){\n          kept.push(passes[dp]);\n          markPassCoverage(passes[dp]);\n        }\n      }\n      passes = kept;\n    }\n    return passes;\n  }\n  \/\/ UNIVERSAL GAP-FILL (rule \u00a76 coverage floor \u2014 rewritten 2026-06-11,\n  \/\/ \"let's try to cover 100%\"). After the primary plan is built (body\n  \/\/ passes + rings + centre-fill, ANY approach), rasterise what its\n  \/\/ swath covers, find the in-field cells still uncovered (boundary-band\n  \/\/ wedges where ring offsets clipped, sliver stubs, hole-adjacent\n  \/\/ notches, concave pockets) and emit SHORT fill passes through just\n  \/\/ those runs. Fill passes ride the same serpentine as body passes, so\n  \/\/ playback + metrics treat them as real work. Honours minimise-km: a\n  \/\/ fill is emitted only where its swath touches an actually-uncovered\n  \/\/ cell, padded 0.4 \u00d7 wM so it blends into neighbours without hairline\n  \/\/ gaps.\n  \/\/\n  \/\/   clipPolyRaw \u2014 the RAW user boundary; every emitted fill is clipped\n  \/\/                 against it (rule \u00a76 containment \u2014 fills never exit).\n  \/\/   rasterPoly  \u2014 cheap reference polygon for the cell raster (the\n  \/\/                 simplified bRef; pointInPoly per cell over a 700-\n  \/\/                 vertex satellite boundary would freeze recompute).\n  \/\/   holesRaw    \u2014 obstacle rings; cells inside are NOT field.\n  \/\/   holeCut     \u2014 directional cut buffers; fills subtract them exactly\n  \/\/                 like primary passes (stop ~1 m before the obstacle).\n  function buildFillPasses(existingPasses, clipPolyRaw, rasterPoly, axis, wM, holesRaw, holeCut){\n    var stats = fieldStats(rasterPoly);\n    var cellSize = Math.max(2, wM \/ 3);\n    var nx = Math.max(8, Math.ceil((stats.maxX - stats.minX) \/ cellSize));\n    var ny = Math.max(8, Math.ceil((stats.maxY - stats.minY) \/ cellSize));\n    var inField = new Uint8Array(nx * ny);\n    var uncoveredCount = 0;\n    for(var iy=0; iy<ny; iy++){\n      for(var ix=0; ix<nx; ix++){\n        var cx = stats.minX + (ix + 0.5) * cellSize;\n        var cy = stats.minY + (iy + 0.5) * cellSize;\n        if(!pointInPoly(cx, cy, rasterPoly)) continue;\n        if(holesRaw ? pointInHoles(cx, cy, holesRaw) : false) continue;\n        inField[iy * nx + ix] = 1;\n        uncoveredCount++;\n      }\n    }\n    var segs = [];\n    for(var pi=0; pi<(existingPasses ? existingPasses.length : 0); pi++){\n      var pa = existingPasses[pi];\n      if(pa.samples){\n        for(var si=1; si<pa.samples.length; si++){\n          segs.push({ x0: pa.samples[si-1].x, y0: pa.samples[si-1].y, x1: pa.samples[si].x, y1: pa.samples[si].y });\n        }\n      } else if(pa.x0 !== undefined){\n        segs.push({ x0: pa.x0, y0: pa.y0, x1: pa.x1, y1: pa.y1 });\n      }\n    }\n    \/\/ Covered marking \u2014 INVERTED loop (per segment, only its bbox cells),\n    \/\/ same pattern as rasterCoverage. The old per-cell \u00d7 per-segment loop\n    \/\/ was O(cells \u00d7 segs) \u2248 8M+ checks on a 200 ha field.\n    var half = wM * 0.5;\n    var half2 = half * half;\n    var covered = new Uint8Array(nx * ny);\n    for(var sg=0; sg<segs.length; sg++){\n      var sd = segs[sg];\n      var sdx = sd.x1 - sd.x0, sdy = sd.y1 - sd.y0;\n      var L2 = sdx*sdx + sdy*sdy;\n      if(L2 < 1e-9) continue;\n      var ixA = Math.max(0,    Math.floor((Math.min(sd.x0, sd.x1) - half - stats.minX) \/ cellSize));\n      var ixB = Math.min(nx-1, Math.floor((Math.max(sd.x0, sd.x1) + half - stats.minX) \/ cellSize));\n      var iyA = Math.max(0,    Math.floor((Math.min(sd.y0, sd.y1) - half - stats.minY) \/ cellSize));\n      var iyB = Math.min(ny-1, Math.floor((Math.max(sd.y0, sd.y1) + half - stats.minY) \/ cellSize));\n      for(var iyS=iyA; iyS<=iyB; iyS++){\n        for(var ixS=ixA; ixS<=ixB; ixS++){\n          var idxS = iyS * nx + ixS;\n          if(!inField[idxS] ? true : covered[idxS]) continue;\n          var cxS = stats.minX + (ixS + 0.5) * cellSize;\n          var cyS = stats.minY + (iyS + 0.5) * cellSize;\n          var t = ((cxS - sd.x0) * sdx + (cyS - sd.y0) * sdy) \/ L2;\n          if(t < 0) t = 0; else if(t > 1) t = 1;\n          var dxC = sd.x0 + sdx * t - cxS;\n          var dyC = sd.y0 + sdy * t - cyS;\n          if(dxC*dxC + dyC*dyC <= half2){\n            covered[idxS] = 1;\n            uncoveredCount--;\n          }\n        }\n      }\n    }\n    if(uncoveredCount <= 0) return [];\n    \/\/ ERODE the uncovered map by one cell (4-neighbourhood). Every plan\n    \/\/ has an unavoidable fringe of partially-covered cells along swath\n    \/\/ edges, boundary rims, and AB-Curve wave troughs \u2014 cells whose\n    \/\/ CENTRES sit just past wM\/2 from a centreline even though most of\n    \/\/ the cell is painted. Filling that fringe is what the metric calls\n    \/\/ chasing noise: the first un-eroded version emitted hundreds of\n    \/\/ short fills hugging every edge (pivot\/ab-curve crossings went\n    \/\/ 0 \u2192 579). After erosion only REAL gaps \u2265 ~1 cell-radius thick\n    \/\/ (boundary-band wedges, sliver parts, concave pockets) remain as\n    \/\/ fill targets; the 0.4 \u00d7 wM end-padding still blends fills into\n    \/\/ the fringe around them.\n    var seed = new Uint8Array(nx * ny);\n    var seedCount = 0;\n    for(var ey=0; ey<ny; ey++){\n      for(var ex=0; ex<nx; ex++){\n        var eIdx = ey * nx + ex;\n        if(!inField[eIdx] ? true : covered[eIdx]) continue;\n        var up    = ey > 0      ? (ey - 1) * nx + ex : -1;\n        var down  = ey < ny - 1 ? (ey + 1) * nx + ex : -1;\n        var left  = ex > 0      ? ey * nx + (ex - 1) : -1;\n        var right = ex < nx - 1 ? ey * nx + (ex + 1) : -1;\n        var gapN = function(ii){ return ii >= 0 ? (inField[ii] ? !covered[ii] : false) : false; };\n        if(gapN(up) ? (gapN(down) ? (gapN(left) ? gapN(right) : false) : false) : false){\n          seed[eIdx] = 1;\n          seedCount++;\n        }\n      }\n    }\n    if(seedCount === 0) return [];\n    \/\/ Candidate fill lines on the pass axis, even-fit edge to edge (same\n    \/\/ grid model as the primary passes). Clipped against the RAW boundary\n    \/\/ so containment holds regardless of the raster approximation.\n    var ux = axis.ux, uy = axis.uy;\n    var perpMin = Infinity, perpMax = -Infinity;\n    var alongMin = Infinity, alongMax = -Infinity;\n    for(var v=0; v<clipPolyRaw.length; v++){\n      var along = clipPolyRaw[v].x * ux + clipPolyRaw[v].y * uy;\n      var perp = -clipPolyRaw[v].x * uy + clipPolyRaw[v].y * ux;\n      if(perp < perpMin) perpMin = perp;\n      if(perp > perpMax) perpMax = perp;\n      if(along < alongMin) alongMin = along;\n      if(along > alongMax) alongMax = along;\n    }\n    var alongSpan = alongMax - alongMin;\n    var sampleStep = wM * 0.4;\n    var fillPasses = [];\n    var fStart = perpMin + wM * 0.5;\n    var fEnd = perpMax - wM * 0.5;\n    var fpList = [];\n    if(fEnd >= fStart - 1e-6){\n      if(fEnd - fStart < 1e-6) fpList.push(fStart);\n      else {\n        var fIv = Math.max(1, Math.ceil((fEnd - fStart) \/ wM - 1e-6));\n        var fSp = (fEnd - fStart) \/ fIv;\n        for(var fk=0; fk<=fIv; fk++) fpList.push(fStart + fSp * fk);\n      }\n    } else if(perpMax - perpMin > wM * 0.5){\n      fpList.push((perpMin + perpMax) * 0.5);\n    }\n    \/\/ Lateral cell probes at \u2212wM\/3, 0, +wM\/3 from the centreline \u2014 the\n    \/\/ fill's swath is wM wide, so an uncovered band sitting beside the\n    \/\/ centreline (between fill lines) still triggers a run.\n    var latOff = [ -wM \/ 3, 0, wM \/ 3 ];\n    for(var fpi=0; fpi<fpList.length; fpi++){\n      var pp = fpList[fpi];\n      var p0Along = alongMin - alongSpan * 0.1;\n      var p1Along = alongMax + alongSpan * 0.1;\n      var startX = p0Along * ux - pp * uy;\n      var startY = p0Along * uy + pp * ux;\n      var endX = p1Along * ux - pp * uy;\n      var endY = p1Along * uy + pp * ux;\n      var clippedSegs = clipLineToBoundarySegments(startX, startY, endX, endY, clipPolyRaw);\n      if(!clippedSegs) continue;\n      if(holeCut) clippedSegs = subtractHolesFromSegs(clippedSegs, holeCut);\n      for(var cs=0; cs<clippedSegs.length; cs++){\n        var clipped = clippedSegs[cs];\n        var dxL = clipped.x1 - clipped.x0;\n        var dyL = clipped.y1 - clipped.y0;\n        var L = Math.sqrt(dxL*dxL + dyL*dyL);\n        if(L < wM * 0.4) continue;\n        var nS = Math.max(4, Math.ceil(L \/ sampleStep));\n        var runStart = -1;\n        var emitRun = function(startT, endT){\n          var pad = (wM * 0.4) \/ L;\n          var t0 = Math.max(0, startT - pad);\n          var t1 = Math.min(1, endT + pad);\n          var segLen = (t1 - t0) * L;\n          if(segLen < wM * 0.45) return;  \/\/ degenerate sliver \u2014 skip\n          fillPasses.push({\n            x0: clipped.x0 + dxL * t0,\n            y0: clipped.y0 + dyL * t0,\n            x1: clipped.x0 + dxL * t1,\n            y1: clipped.y0 + dyL * t1,\n            kind: 'gap-fill'\n          });\n        };\n        for(var ks=0; ks<=nS; ks++){\n          var tt = ks \/ nS;\n          var sxk = clipped.x0 + dxL * tt;\n          var syk = clipped.y0 + dyL * tt;\n          var inGap = false;\n          for(var lo=0; lo<latOff.length; lo++){\n            var lxk = sxk + -uy * latOff[lo];\n            var lyk = syk + ux * latOff[lo];\n            var ixk = Math.floor((lxk - stats.minX) \/ cellSize);\n            var iyk = Math.floor((lyk - stats.minY) \/ cellSize);\n            if(ixk >= 0 ? iyk >= 0 ? ixk < nx ? iyk < ny : false : false : false){\n              if(seed[iyk * nx + ixk]){ inGap = true; break; }\n            }\n          }\n          if(inGap){\n            if(runStart < 0) runStart = tt;\n          } else {\n            if(runStart >= 0){ emitRun(runStart, tt); runStart = -1; }\n          }\n        }\n        if(runStart >= 0) emitRun(runStart, 1);\n      }\n    }\n    return fillPasses;\n  }\n  function generateLines(approach, wM, b, axis, headlandM, turnStyle, turnRadiusM, outsideBufferM, suppressHeadland){\n    \/\/ OFFSET \/ TURN REFERENCE polygon (rule \u00a720 \u2014 internal derivations MAY\n    \/\/ simplify; the user's boundary itself is never touched). Dense\n    \/\/ satellite-digitized boundaries (700+ vertices with metre-scale\n    \/\/ sawtooth jags) break two things: (1) the inward offset folds at\n    \/\/ nearly every jag \u2192 repairSelfIntersections clips off whole lobes \u2192\n    \/\/ unworked headland bands (~6.6 ha missed on a 207 ha field); (2) the\n    \/\/ jagged `safe` polygon makes U-turn-arc fitting + traverse-inside\n    \/\/ checks FAIL near the edge, so every adjacent-band turn falls through\n    \/\/ to a perimeter ring-route (p064\/r038 drove 80+ ring-routes \u2248 250 km,\n    \/\/ reported 2026-06-15). Both want the SMOOTHED reference. Tolerance\n    \/\/ capped at 4 m merge \/ 1.5 m collinear (jags are 1-3 m). Body-pass\n    \/\/ clipping, coverage, exports, and the containment TEST still use raw\n    \/\/ `b`; only offsets + the drivable\/safe used for turn geometry use bRef.\n    var bRef = b;\n    if(b.length > 60){\n      var bSimp = simplifyPolygon(b, Math.min(wM * 0.25, 4), Math.min(wM * 0.1, 1.5));\n      if(bSimp ? bSimp.length >= 4 : false){\n        if(!polygonSelfIntersects(bSimp)) bRef = bSimp;\n      }\n    }\n    var drivable = buildDrivable(bRef, outsideBufferM);\n    \/\/ suppressHeadland: caller is treating `b` as a body-only block inside\n    \/\/ an already-trimmed inner workable polygon (auto-blocks decomposition\n    \/\/ case). Skip both the inward offset AND the perimeter rings \u2014 body\n    \/\/ passes are allowed to reach the block edge so internal cut edges\n    \/\/ stay open and adjacent blocks meet at the split line.\n    var skipHeadland = suppressHeadland === true;\n    var interior = (headlandM > 0 ? !skipHeadland : false) ? offsetPolygonInward(bRef, headlandM) : b;\n    if(!interior) interior = b;  \/\/ fallback when offset collapses\n    \/\/ Rule \u00a76 \u2014 repair the inset polygon if it self-intersects at sharp\n    \/\/ acute boundary tips. Keep the larger-area side; this matches the\n    \/\/ operator's intent (\"headland is a buffer band, narrower at tips\").\n    if(interior !== b ? polygonSelfIntersects(interior) : false){\n      var interiorRepaired = repairSelfIntersections(interior);\n      if(interiorRepaired ? interiorRepaired.length >= 4 : false) interior = interiorRepaired;\n    }\n    var ux = axis.ux, uy = axis.uy;\n    var passes = [];\n    \/\/ Obstacles \/ holes (rule \u00a76 `obstacle_boundary`). Buffer each hole\n    \/\/ outward by wM\/2 \u2014 an obstacle headland so the implement keeps\n    \/\/ clearance from the pond\/woodlot\/pole edge. Body passes subtract\n    \/\/ these buffered rings; U-turn arcs + transports must avoid the\n    \/\/ UN-buffered hole interior (handed to buildSerpentine).\n    var fieldHoles = (b.holes ? b.holes.length > 0 : false) ? b.holes : null;\n    var holeBuffers = null;\n    if(fieldHoles){\n      holeBuffers = [];\n      for(var hb=0; hb<fieldHoles.length; hb++){\n        var hbuf = expandLoopOutward(fieldHoles[hb], wM * 0.5);\n        holeBuffers.push(hbuf ? hbuf : fieldHoles[hb]);\n      }\n    }\n    \/\/ Compute the U-turn arc clearance FIRST so the ring-emission logic\n    \/\/ below can detect \"headland=0 but pullBack > 0\" \u2014 the case where\n    \/\/ body passes are forced inward by arc geometry, leaving an\n    \/\/ uncovered strip at the boundary edge unless we add an auto-ring.\n    var passClipPoly = interior;\n    var arcDepthNeeded0 = Math.max(wM * 0.5, turnRadiusM);\n    var safeBuffer0 = wM * 0.25 + 1;\n    var pullBackPrecheck = Math.max(0, arcDepthNeeded0 + safeBuffer0 - headlandM);\n    \/\/ Rule \u00a713 \u2014 work the headland FIRST, then the body. Emit perimeter\n    \/\/ ring(s) at headland-strip centrelines for EVERY approach.\n    \/\/\n    \/\/ For headlandM == 1\u00d7wM: one ring at wM\/2 from boundary covers the\n    \/\/ whole strip [0, wM] with its swath.\n    \/\/\n    \/\/ For headlandM > 1\u00d7wM: drive MULTIPLE concentric rings so the full\n    \/\/ strip is covered.\n    \/\/\n    \/\/ For headlandM == 0 AND pullBackPrecheck > 0: the arc-geometry\n    \/\/ pullBack forces body passes ~12 m inward from the boundary even\n    \/\/ though the user opted for no headland. Without an auto-ring, the\n    \/\/ strip 0\u2013pullBack stays UNCOVERED (visible coverage hit of 5-15 %\n    \/\/ on small fields). Emit one perimeter ring at wM\/2 to cover that\n    \/\/ strip. Reported 2026-06-03 \u2014 \"in case headland is decreased to 0,\n    \/\/ need to generate more lines, to cover more\".\n    var ringHeadlandM = headlandM > 0 ? headlandM : (pullBackPrecheck > wM * 0.25 ? wM : 0);\n    if(skipHeadland) ringHeadlandM = 0;\n    if(ringHeadlandM > 0){\n      \/\/ Always try ring(s) when the user requested a headland, even on tiny\n      \/\/ parts where the interior collapsed (interior === b fallback). The\n      \/\/ previous \"interior !== b\" gate silently dropped rings on small\n      \/\/ multi-polygon parts, leaving the headland strip there visibly\n      \/\/ unworked. Each ring is still gated on offsetPolygonInward success;\n      \/\/ if a particular ring offset is too deep for a tiny part it's\n      \/\/ skipped individually rather than skipping all rings.\n      var nRings = Math.max(1, Math.round(ringHeadlandM \/ wM));\n      for(var rk=0; rk<nRings; rk++){\n        var ringOff = (rk + 0.5) * wM;\n        var ringRef = offsetPolygonInward(bRef, ringOff);\n        if(ringRef ? ringRef.length >= 4 : false){\n          \/\/ Rule \u00a76 \u2014 inner-ring offsets MUST be checked for self-\n          \/\/ intersection. On irregular W-shape boundaries with multiple\n          \/\/ sharp acute tips (lv Eichwiese with 3 concave Vs), the\n          \/\/ inward offset folds back at EACH V. Repair iteratively;\n          \/\/ re-validate after; SKIP the ring entirely if still self-\n          \/\/ crossing \u2014 a self-intersecting ring is geometrically\n          \/\/ impossible for a machine to drive (the path crosses itself\n          \/\/ at the apex), reported 2026-06-04 with screenshot of the\n          \/\/ bowtie pattern. Better to drop one ring than render an\n          \/\/ un-drivable path.\n          if(polygonSelfIntersects(ringRef)){\n            var repaired = repairSelfIntersections(ringRef);\n            if(repaired ? repaired.length >= 4 : false) ringRef = repaired;\n            else continue;\n          }\n          \/\/ Re-validate after repair \u2014 multi-V boundaries can still\n          \/\/ self-intersect after one repair pass.\n          if(polygonSelfIntersects(ringRef)) continue;\n          \/\/ Round sharp polygon corners to the equipment's min turn radius\n          \/\/ \u2014 real machines can't pivot at a sharp vertex.\n          var ringSmooth = roundPolygonCorners(ringRef, turnRadiusM);\n          \/\/ Final guard \u2014 corner rounding can re-introduce crossings on\n          \/\/ very acute reflex vertices because the arc tangent calc\n          \/\/ assumes convex corners. If it does, fall back to the\n          \/\/ un-rounded repaired polygon.\n          if(polygonSelfIntersects(ringSmooth.concat([ringSmooth[0]]))) ringSmooth = ringRef;\n          if(polygonSelfIntersects(ringSmooth.concat([ringSmooth[0]]))) continue;\n          \/\/ Obstacle handling \u2014 split the perimeter ring into open arcs\n          \/\/ wherever it would cross a hole (rule \u00a76). No-op when no holes.\n          var permClosed = ringSmooth.concat([ringSmooth[0]]);\n          if(holeBuffers){\n            var permArcs = splitRingAroundHoles(permClosed, holeBuffers);\n            for(var pa2=0; pa2<permArcs.length; pa2++){\n              if(permArcs[pa2].length >= 2) passes.push({ samples: permArcs[pa2], kind: 'headland-ring' });\n            }\n          } else {\n            passes.push({ samples: permClosed, kind: 'headland-ring' });\n          }\n        }\n      }\n    }\n    \/\/ Rule \u00a76 \u2014 U-turn arcs must always stay INSIDE the field boundary (or\n    \/\/ headland strip if one exists). The half-circle U-turn bulges outward\n    \/\/ from the chord by max(wM\/2, turnRadiusM). If the headland strip is\n    \/\/ narrower than that bulge (or there's no headland at all), the arc\n    \/\/ exits the boundary \u2014 the operator's physical machine can't actually\n    \/\/ drive that path.\n    \/\/\n    \/\/ Fix: pull body-pass endpoints further INWARD so the arc has room to\n    \/\/ swing without exiting the boundary. The operator starts pivoting in\n    \/\/ advance \u2014 body pass ends at depth `arcDepth` from the inner-headland\n    \/\/ edge, machine pivots, arc bulges back to the inner-headland edge,\n    \/\/ next pass begins.\n    \/\/\n    \/\/ pullBack = max(0, arcDepth \u2212 headlandM) + half-implement safety. Zero\n    \/\/ on default settings (headland 1\u00d7wM \u2265 arc bulge wM\/2), positive only\n    \/\/ when headland is tight relative to turn radius or implement width.\n    \/\/ The +halfImpl\u00d70.5 matches the `safe` polygon inset used by\n    \/\/ arcInsidePoly in buildTurnArc \u2014 without it the arc tip lands exactly\n    \/\/ on the boundary and the inside-safe check rejects the arc.\n    \/\/ Strip from pulled-back-edge to inner-headland-edge is left unworked\n    \/\/ (coverage % drops to reflect this \u2014 the operator either accepts the\n    \/\/ gap or widens the headland).\n    \/\/ Apply the pullBack inset (precomputed above as arcDepthNeeded0 +\n    \/\/ safeBuffer0 - headlandM, here named ringHeadlandM for the ring\n    \/\/ case where headland=0 still gets effective inset). The body-pass\n    \/\/ clipper uses passClipPoly = boundary inset by pullBack so U-turn\n    \/\/ arc bulges have room to swing without leaking outside.\n    var pullBack = pullBackPrecheck;\n    \/\/ When suppressHeadland is set (auto-blocks sub-block where the outer\n    \/\/ headland already lives on the parent polygon and the cut edge is an\n    \/\/ OPEN internal split), let body passes reach the sub-block edge. The\n    \/\/ U-turn arcs may bulge past the cut edge into the next sub-block \u2014\n    \/\/ that's fine because the next sub-block is part of the same field.\n    if(skipHeadland) pullBack = 0;\n    if(pullBack > 0){\n      var pulled = offsetPolygonInward(bRef, headlandM + pullBack);\n      if(pulled ? pulled.length >= 4 : false) passClipPoly = pulled;\n    }\n    \/\/ Boundary Follow used to shrink the body interior by another 0.75 \u00d7 wM\n    \/\/ to avoid overlap between ring and body swaths. That created a 0.75 \u00d7 wM\n    \/\/ UNCOVERED strip between the ring's swath edge and the first body pass.\n    \/\/ Per the user \u2014 maximise coverage \u2014 body passes now use the full\n    \/\/ interior, accepting a small (~0\u201325 %) overlap at the ring\/body\n    \/\/ boundary, which is standard agronomic practice (5\u201310 % overlap is\n    \/\/ routine for headland edges).\n    \/\/ Topography follow (= 'adaptive') \u2014 gradient-perpendicular parallel\n    \/\/ passes. The original implementation walked actual elevation contours;\n    \/\/ it produced lots of short crossing curves on real terrains and the\n    \/\/ user explicitly rejected the result (\"too big skips, too many\n    \/\/ intersections, acute angles\"). The new implementation:\n    \/\/\n    \/\/   1. Sample the terrain gradient at a 5\u00d75 grid inside passClipPoly.\n    \/\/   2. Compute the average gradient direction (weighted by magnitude).\n    \/\/   3. Re-orient the pass axis PERPENDICULAR to mean gradient \u2014 so\n    \/\/      passes run along the LEVEL CURVES (zero slope cost per metre).\n    \/\/   4. Fall through to the AB-Straight pass generation below.\n    \/\/\n    \/\/ Result: same coverage as AB-Straight at a different axis. Smooth\n    \/\/ U-turns (existing serpentine machinery), 0 self-crossings, no\n    \/\/ acute angles. On a tilted plane (Rect \/ Hex terrain) this is\n    \/\/ geometrically correct \u2014 passes follow level curves exactly. On\n    \/\/ central-hill terrains (Pivot) average gradient \u2248 0 \u2192 falls back\n    \/\/ to the field axis (= AB-Straight orientation).\n    if(approach === 'adaptive'){\n      var topoStats = fieldStats(passClipPoly);\n      var gxSum = 0, gySum = 0, gMagSum = 0;\n      var GRID = 5;\n      for(var gxi=0; gxi<GRID; gxi++){\n        for(var gyi=0; gyi<GRID; gyi++){\n          var sx = topoStats.minX + (gxi + 0.5) * (topoStats.maxX - topoStats.minX) \/ GRID;\n          var sy = topoStats.minY + (gyi + 0.5) * (topoStats.maxY - topoStats.minY) \/ GRID;\n          if(!pointInPoly(sx, sy, passClipPoly)) continue;\n          var gxS = (terrainAt(sx + 1, sy) - terrainAt(sx - 1, sy)) * 0.5;\n          var gyS = (terrainAt(sx, sy + 1) - terrainAt(sx, sy - 1)) * 0.5;\n          var gMagS = Math.sqrt(gxS * gxS + gyS * gyS);\n          gxSum += gxS * gMagS;  \/\/ weighted by magnitude\n          gySum += gyS * gMagS;\n          gMagSum += gMagS;\n        }\n      }\n      \/\/ If the dominant gradient is strong enough, rotate the pass axis\n      \/\/ to be perpendicular to it. Otherwise keep the existing axis\n      \/\/ (= field's longest edge) so the algorithm falls back to AB-\n      \/\/ Straight behaviour on near-flat fields.\n      var gAvgMag = gMagSum > 0 ? Math.sqrt(gxSum * gxSum + gySum * gySum) \/ gMagSum : 0;\n      if(gAvgMag > 0.005){\n        var gNx = gxSum \/ Math.sqrt(gxSum * gxSum + gySum * gySum);\n        var gNy = gySum \/ Math.sqrt(gxSum * gxSum + gySum * gySum);\n        \/\/ Concave-axis guard (rule \u00a76 \u2014 machine never leaves boundary).\n        \/\/ The gradient-perpendicular axis may run DIAGONALLY across a\n        \/\/ concave field (L-shape, T-shape, multi-arm) \u2192 a single pass-\n        \/\/ line clips into TWO segments, one per arm, and the U-turn \/\n        \/\/ transport between them cuts the corner OUTSIDE the polygon.\n        \/\/ Reported 2026-06-03 on L-shape Topography at headland=0.\n        \/\/ Fix: probe the candidate axis with a centre-spanning test\n        \/\/ line. If the polygon clips it into > 1 segment, the axis is\n        \/\/ concavity-crossing \u2014 revert to the field's longest-edge axis\n        \/\/ (the original ux\/uy), which is by construction parallel to\n        \/\/ one of the polygon's edges and won't slice across notches.\n        var candUx = -gNy, candUy = gNx;\n        var tcx = (topoStats.minX + topoStats.maxX) * 0.5;\n        var tcy = (topoStats.minY + topoStats.maxY) * 0.5;\n        var tSpan = Math.sqrt(Math.pow(topoStats.maxX - topoStats.minX, 2) + Math.pow(topoStats.maxY - topoStats.minY, 2));\n        var tx0 = tcx - candUx * tSpan, ty0 = tcy - candUy * tSpan;\n        var tx1 = tcx + candUx * tSpan, ty1 = tcy + candUy * tSpan;\n        var tSegs = clipLineToBoundarySegments(tx0, ty0, tx1, ty1, passClipPoly);\n        var concaveAxis = (tSegs ? tSegs.length > 1 : false);\n        if(!concaveAxis){\n          \/\/ Perpendicular direction (pass axis = along level curves)\n          ux = candUx;\n          uy = candUy;\n          \/\/ axis is the bag of {ux, uy} passed to buildSerpentine \u2014 needs\n          \/\/ to use the rotated axis too so the U-turn arc geometry sits\n          \/\/ along the right perpendicular direction.\n          axis = { ux: ux, uy: uy };\n        }\n        \/\/ else: candidate axis would slice across a concave notch, keep\n        \/\/ the original longest-edge axis (no rotation).\n      }\n      \/\/ Fall through to the parallel-pass generation below (the AB-\n      \/\/ Straight code path). The 'adaptive' approach value will still\n      \/\/ trigger the slight curve amplitude in that block.\n    }\n    \/\/ Directional hole-cut buffers \u2014 built HERE (after the Topography\n    \/\/ gradient rotation above) so they use the FINAL pass axis. Straight\n    \/\/ passes subtract these (lateral wM\/2, longitudinal ~1 m \u2192 the pass\n    \/\/ works right up to the obstacle edge); arcs \/ rings \/ wave guards\n    \/\/ keep the isotropic wM\/2 `holeBuffers`.\n    var holeCutBuffers = null;\n    if(fieldHoles){\n      holeCutBuffers = [];\n      for(var hcb=0; hcb<fieldHoles.length; hcb++){\n        holeCutBuffers.push(dirHoleBuffer(fieldHoles[hcb], ux, uy, wM));\n      }\n    }\n    \/\/ Boundary Follow \u2014 concentric closed-loop passes that spiral inward\n    \/\/ from the boundary, each offset by wM. Visually + agronomically\n    \/\/ distinct from AB Straight: the operator works the field by hugging\n    \/\/ the boundary contour all the way to the center, rather than mowing\n    \/\/ back-and-forth in straight rows. Most useful on irregular fields\n    \/\/ where parallel straight passes leave wedge-shaped gaps at every\n    \/\/ boundary curve.\n    if(approach === 'boundary'){\n      \/\/ Dense uploaded boundaries (50+ vertices per few hundred metres\n      \/\/ on real-world fields like the \"weg\" sample) make\n      \/\/ offsetPolygonInward produce rings with adjacent vertices <1 m\n      \/\/ apart after just one inward offset \u2014 the minEdge guard below\n      \/\/ then kills the ring loop after only 2 rings, dropping coverage\n      \/\/ to ~10 %. Simplify the offset rings (not the input boundary)\n      \/\/ so the loop survives without changing the field's drivable\n      \/\/ shape. Simplifying the INPUT boundary would shift rings\n      \/\/ INWARD at simplified-out concave features and let drive paths\n      \/\/ leak outside the original boundary (rule \u00a76 violation).\n      \/\/ After the offsetPolygonInward correction (sin(\u03b8\/2) instead of\n      \/\/ cos(\u03b8\/2)), the offset polygon's edges sit at exact perpendicular\n      \/\/ distance distM from the original boundary \u2014 no more cap-firing\n      \/\/ compensation needed. Keep the cap-firing detection (capFireFrac)\n      \/\/ so the minEdgeLimit and self-intersect handling can still react\n      \/\/ to dense\/curved boundaries, but the ring SPACING is now always wM.\n      var capFireCount = 0;\n      for(var cvi=0; cvi<b.length; cvi++){\n        var pcI = (cvi - 1 + b.length) % b.length;\n        var ncI = (cvi + 1) % b.length;\n        var ce1x = b[cvi].x - b[pcI].x, ce1y = b[cvi].y - b[pcI].y;\n        var ce2x = b[ncI].x - b[cvi].x, ce2y = b[ncI].y - b[cvi].y;\n        var cl1 = Math.sqrt(ce1x * ce1x + ce1y * ce1y);\n        var cl2 = Math.sqrt(ce2x * ce2x + ce2y * ce2y);\n        if(cl1 < 1e-6 ? true : cl2 < 1e-6) continue;\n        var cn1x = -ce1y \/ cl1, cn1y = ce1x \/ cl1;\n        var cn2x = -ce2y \/ cl2, cn2y = ce2x \/ cl2;\n        var cFull = cn1x * cn2x + cn1y * cn2y;\n        \/\/ cosHalf = \u221a((1+cosFull)\/2) = sin(\u03b8\/2); near-1 for obtuse\n        \/\/ (gently curved) corners, near-0 for sharp ones.\n        var cCosHalf = Math.sqrt((1 + cFull) * 0.5);\n        if(cCosHalf > 0.95) capFireCount++;\n      }\n      var capFireFrac = capFireCount \/ Math.max(1, b.length);\n      var stepFactor = 1.0;  \/\/ ring spacing = wM (offset now exact)\n      var bfWmStep = wM;\n      var bfIdx = 0;\n      \/\/ Inner-ring stop. Two modes:\n      \/\/   \u2022 Cap-firing polygons (Pivot, curved uploads, Hex 120\u00b0) use\n      \/\/     adaptive spacing \u2192 rings tile the centre. Floor at\n      \/\/     turnRadiusM (geometric drive limit only) so rings go deep.\n      \/\/   \u2022 Sharp convex polygons (Rect 90\u00b0 corners) need the\n      \/\/     wM \u00d7 1.5 floor to prevent inner-ring pile-up creating\n      \/\/     the ring-jump-star X-pattern of crossings in the centre.\n      \/\/\n      \/\/ Concave polygons (L-shape, irregular uploads with reflex\n      \/\/ vertices) are a known limitation: our offsetPolygonInward\n      \/\/ doesn't properly handle topology splits at the concavity, so\n      \/\/ the rings can't reach the deep interior of both arms. The\n      \/\/ loop stops naturally when offsetPolygonInward fails or the\n      \/\/ sharp-convex floor is hit. Coverage on those combos stays\n      \/\/ around 80-85 % \u2014 the rule \u00a76 floor is met, just not by a\n      \/\/ comfortable margin. Tracked in tests.js KNOWN_LOW_COVERAGE.\n      \/\/ Cap-firing floor at turnRadiusM (geometric drive limit only). On\n      \/\/ tight inner rings where the polygon edges drop below 2 \u00d7 turn\n      \/\/ radius, roundPolygonCorners produces degenerate fillets \u2014 we\n      \/\/ SKIP corner rounding for those rings instead of stopping the\n      \/\/ loop early. The renderer's lineJoin='round' fills the corners\n      \/\/ visually; the drive path itself has sharp corners but the\n      \/\/ interior angle (~120\u00b0 on Hex, \u2265 90\u00b0 on Rect) is well above the\n      \/\/ rule \u00a76 acute-angle floor.\n      \/\/ Sharp polygons (Rect, 90\u00b0 corners \u2014 stepFactor \u2248 1.0): keep\n      \/\/ wM \u00d7 1.5 floor to prevent ring-jump-star X-pattern crossings\n      \/\/ in the centre as rings pile up tightly.\n      \/\/\n      \/\/ Cap-firing polygons (Hex, Pivot, dense uploaded boundaries \u2014\n      \/\/ stepFactor < 0.95): use a much smaller floor (wM \u00d7 0.1). The\n      \/\/ tight floor at turnRadiusM was killing the ring loop on dense\n      \/\/ boundaries like the \"weg\" sample at ring 2 (minE = 5 m, just\n      \/\/ under 6 m floor) \u2014 coverage dropped to 10 %. Topology breakage\n      \/\/ is caught by polygonSelfIntersects below, and roundPolygonCorners\n      \/\/ is already skipped when minE < turnRadiusM \u00d7 2, so a permissive\n      \/\/ minEdge floor doesn't introduce visual artefacts.\n      var minEdgeLimit = stepFactor < 0.95\n        ? Math.max(wM * 0.1, 0.5)\n        : Math.max(turnRadiusM * 2, wM * 1.5);\n      var lastRingOff = -1;\n      var lastValidRing = null;\n      while(bfIdx < 400){\n        var bfOff = (bfIdx + 0.5) * bfWmStep;\n        var bfRingRaw = offsetPolygonInward(bRef, bfOff, 0.01);\n        if(!bfRingRaw ? true : bfRingRaw.length < 4) break;\n        \/\/ Simplify each offset ring before evaluating minEdge. Without\n        \/\/ this, ring vertices that drifted < 1 m apart during the\n        \/\/ offset trip the minEdge guard prematurely (caps coverage at\n        \/\/ ~10 % on dense boundaries). Same tolerances as the input\n        \/\/ simplification \u2014 keeps shape, removes the noise.\n        var bfRing = simplifyPolygon(bfRingRaw, wM * 0.25, wM * 0.125);\n        if(!bfRing ? true : bfRing.length < 4) break;\n        if(headlandM > 0 ? bfOff <= headlandM : false){\n          \/\/ Track even pre-headland-skip rings as the inset basis. On\n          \/\/ dense uploaded boundaries where the FIRST post-headland\n          \/\/ offset already self-intersects, lastValidRing would\n          \/\/ otherwise stay null and the centre-fill block below\n          \/\/ wouldn't fire \u2014 coverage collapses to whatever the single\n          \/\/ outer headland ring covers (~10 %). The pre-headland ring\n          \/\/ sits INSIDE the headland strip; the centre-fill inset of\n          \/\/ wM\/2 starts the body passes wM past the headland edge,\n          \/\/ which is the correct geometric position.\n          lastValidRing = bfRing;\n          lastRingOff = bfOff;\n          bfIdx++;\n          continue;\n        }\n        var minE = Infinity;\n        for(var ev=0; ev<bfRing.length; ev++){\n          var ev2 = (ev + 1) % bfRing.length;\n          var edx = bfRing[ev2].x - bfRing[ev].x;\n          var edy = bfRing[ev2].y - bfRing[ev].y;\n          var elen = Math.sqrt(edx*edx + edy*edy);\n          if(elen < minE) minE = elen;\n        }\n        if(minE < minEdgeLimit) break;\n        \/\/ Reject self-intersecting offset polygons. On very tight inner\n        \/\/ rings of concave-ish polygons, offsetPolygonInward can fold\n        \/\/ the polygon back on itself \u2014 the vertex order then jumps\n        \/\/ wildly across the field, producing degenerate near-180\u00b0\n        \/\/ pivots in the drive path (rule \u00a76 no-acute-angles violation).\n        if(polygonSelfIntersects(bfRing)) break;\n        \/\/ Skip corner rounding on tight inner rings. When any edge is\n        \/\/ shorter than 2 \u00d7 turn radius, roundPolygonCorners' arc\n        \/\/ fillets at adjacent vertices overlap and produce degenerate\n        \/\/ pivots. The renderer's lineJoin = 'round' fills the visible\n        \/\/ corners; the un-rounded ring still looks smooth.\n        var bfPoly = minE < turnRadiusM * 2\n          ? bfRing\n          : roundPolygonCorners(bfRing, turnRadiusM);\n        \/\/ Obstacle handling \u2014 a Boundary-Follow ring that crosses a hole\n        \/\/ is split into open sub-arcs that drive AROUND the obstacle\n        \/\/ (rule \u00a76 \u2014 machine never enters a no-go zone). On a field with\n        \/\/ no holes this is a no-op (one closed loop).\n        var ringClosed = bfPoly.concat([bfPoly[0]]);\n        if(holeBuffers){\n          var ringArcs = splitRingAroundHoles(ringClosed, holeBuffers);\n          for(var ra=0; ra<ringArcs.length; ra++){\n            if(ringArcs[ra].length >= 2) passes.push({ samples: ringArcs[ra], kind: 'headland-ring' });\n          }\n        } else {\n          passes.push({ samples: ringClosed, kind: 'headland-ring' });\n        }\n        lastRingOff = bfOff;\n        lastValidRing = bfRing;  \/\/ pre-rounding for cleaner inset later\n        bfIdx++;\n      }\n      \/\/ CENTRE FILL \u2014 axis-aligned passes inside the un-ringed core to\n      \/\/ hit the rule \u00a76 \u2265 80 % coverage floor. The ring loop stops on\n      \/\/ either the geometric drive limit (minEdgeLimit) or the\n      \/\/ self-intersect guard above. Whatever's left in the middle gets\n      \/\/ covered by parallel passes oriented on the field axis.\n      \/\/\n      \/\/ To minimise overlap with the innermost ring's swath and to\n      \/\/ minimise crossings with ring centerlines, the fill is clipped\n      \/\/ to the polygon INSET past the last ring (corePoly), and each\n      \/\/ pass's ends are shrunk so its U-turn arcs stay inside corePoly.\n      \/\/\n      \/\/ corePoly is derived DIRECTLY from the input boundary `b` at\n      \/\/ offset max(headlandM, lastRingOff) + wM\/2 \u2014 NOT from\n      \/\/ lastValidRing. Rationale: lastValidRing can be a tight inner\n      \/\/ ring or, on dense concave boundaries, an offset of bSimp that\n      \/\/ approximates `b` only loosely. Building corePoly off such a\n      \/\/ ring produces fills that leak outside `b` near concave\n      \/\/ notches (drive paths exit the field \u2014 rule \u00a76 violation).\n      \/\/ Anchoring on `b` guarantees corePoly is a true inset and\n      \/\/ stays inside the field even on concave shapes.\n      var bodyStartOff = Math.max(headlandM, lastRingOff) + wM * 0.5;\n      if(bodyStartOff < wM * 0.5) bodyStartOff = wM * 0.5;\n      if(lastValidRing ? lastValidRing.length >= 3 : false){\n        var corePoly = offsetPolygonInward(bRef, bodyStartOff, 0.01);\n        if(corePoly ? corePoly.length >= 4 : false){\n          var cMnP = Infinity, cMxP = -Infinity;\n          var cMnA = Infinity, cMxA = -Infinity;\n          for(var ci=0; ci<corePoly.length; ci++){\n            var cAl = corePoly[ci].x * ux + corePoly[ci].y * uy;\n            var cPr = -corePoly[ci].x * uy + corePoly[ci].y * ux;\n            if(cAl < cMnA) cMnA = cAl;\n            if(cAl > cMxA) cMxA = cAl;\n            if(cPr < cMnP) cMnP = cPr;\n            if(cPr > cMxP) cMxP = cPr;\n          }\n          var cAlSpan = cMxA - cMnA;\n          \/\/ Even-spacing grid fit for the centre-fill \u2014 same rule \u00a76 fix\n          \/\/ as the body-pass grid: distribute the span remainder as a\n          \/\/ small uniform overlap instead of leaving an unworked strip\n          \/\/ at the far edge of the core.\n          var cppList = [];\n          var cStart = cMnP + wM * 0.5;\n          var cEnd = cMxP - wM * 0.5;\n          if(cEnd >= cStart - 1e-6){\n            if(cEnd - cStart < 1e-6){\n              cppList.push(cStart);\n            } else {\n              var cIv = Math.max(1, Math.ceil((cEnd - cStart) \/ wM - 1e-6));\n              var cSp = (cEnd - cStart) \/ cIv;\n              for(var ck=0; ck<=cIv; ck++) cppList.push(cStart + cSp * ck);\n            }\n          } else if(cMxP - cMnP > wM * 0.5){\n            cppList.push((cMnP + cMxP) * 0.5);\n          }\n          for(var cppi=0; cppi<cppList.length; cppi++){\n            var cpp = cppList[cppi];\n            var cP0 = cMnA - cAlSpan * 0.1;\n            var cP1 = cMxA + cAlSpan * 0.1;\n            var cSx = cP0 * ux - cpp * uy;\n            var cSy = cP0 * uy + cpp * ux;\n            var cEx = cP1 * ux - cpp * uy;\n            var cEy = cP1 * uy + cpp * ux;\n            \/\/ Clip to corePoly first (constrains the body-fill region to\n            \/\/ past the headland strip), then RE-CLIP each surviving sub-\n            \/\/ segment against the ORIGINAL boundary `b`. On concave\n            \/\/ boundaries (weg's diagonal notch) the miter-cap offset\n            \/\/ polygon can have vertices that fall OUTSIDE b \u2014 clipping\n            \/\/ to corePoly alone would let a body-fill segment cross into\n            \/\/ those bad regions, and the drive path then exits the\n            \/\/ field (rule \u00a76 violation). The second clip against `b`\n            \/\/ guarantees containment.\n            \/\/ Clip the long axis-aligned line to corePoly, then validate\n            \/\/ each resulting sub-segment is fully inside the original\n            \/\/ boundary `b`. corePoly can extend OUTSIDE `b` near deep\n            \/\/ concave notches (the miter-cap offset bridges across the\n            \/\/ concavity); without the second containment check, a body\n            \/\/ fill segment leaks into the area outside the field and\n            \/\/ the drive path exits the boundary (rule \u00a76 violation).\n            \/\/\n            \/\/ Validation strategy: sample the segment at a handful of\n            \/\/ points; if any sample is outside `b`, re-clip the segment\n            \/\/ against `b` to extract only the inside portion(s).\n            \/\/ clipLineToBoundarySegments needs \u2265 2 edge-intersections to\n            \/\/ return; for fully-inside lines (0 intersections) it\n            \/\/ returns null \u2014 that's the cheap path where we keep the\n            \/\/ segment unmodified.\n            var coreSegs = clipLineToBoundarySegments(cSx, cSy, cEx, cEy, corePoly);\n            if(!coreSegs) continue;\n            for(var cs=0; cs<coreSegs.length; cs++){\n              var cseg = coreSegs[cs];\n              \/\/ Sample a few points along cseg to detect partial-outside segments.\n              var anyOutside = false;\n              for(var sp=0; sp<=4; sp++){\n                var t = sp * 0.25;\n                var sx = cseg.x0 + (cseg.x1 - cseg.x0) * t;\n                var sy = cseg.y0 + (cseg.y1 - cseg.y0) * t;\n                if(!pointInPoly(sx, sy, b)){ anyOutside = true; break; }\n              }\n              var candidateSegs;\n              if(!anyOutside){\n                candidateSegs = [cseg];\n              } else {\n                \/\/ Mixed\/outside: re-clip against b. Extend endpoints\n                \/\/ past the segment so clip yields well-formed sub-segs\n                \/\/ (the clipper needs \u2265 2 edge crossings).\n                var extDx = cseg.x1 - cseg.x0;\n                var extDy = cseg.y1 - cseg.y0;\n                var extLen = Math.sqrt(extDx * extDx + extDy * extDy);\n                if(extLen < 1e-9) continue;\n                var ex0 = cseg.x0 - (extDx \/ extLen) * 1;\n                var ey0 = cseg.y0 - (extDy \/ extLen) * 1;\n                var ex1 = cseg.x1 + (extDx \/ extLen) * 1;\n                var ey1 = cseg.y1 + (extDy \/ extLen) * 1;\n                candidateSegs = clipLineToBoundarySegments(ex0, ey0, ex1, ey1, b);\n                if(!candidateSegs) continue;\n              }\n              \/\/ Subtract obstacle\/hole interiors from the centre-fill too.\n              if(holeCutBuffers) candidateSegs = subtractHolesFromSegs(candidateSegs, holeCutBuffers);\n              for(var bs=0; bs<candidateSegs.length; bs++){\n                var bseg = candidateSegs[bs];\n                var clx = bseg.x1 - bseg.x0, cly = bseg.y1 - bseg.y0;\n                if(clx * clx + cly * cly < (wM * 0.4) * (wM * 0.4)) continue;\n                passes.push({\n                  x0: bseg.x0, y0: bseg.y0,\n                  x1: bseg.x1, y1: bseg.y1,\n                  kind: 'boundary-fill'\n                });\n              }\n            }\n          }\n        }\n      }\n      \/\/ Universal gap-fill (rule \u00a76 coverage floor) \u2014 patch whatever the\n      \/\/ rings + centre-fill left uncovered before the serpentine is built.\n      if(!skipHeadland){\n        var gapFillsB = buildFillPasses(passes, b, bRef, { ux: ux, uy: uy }, wM, fieldHoles, holeCutBuffers);\n        for(var gfb=0; gfb<gapFillsB.length; gfb++) passes.push(gapFillsB[gfb]);\n      }\n      var serpB = buildSerpentine(passes, drivable, interior, axis, turnStyle, turnRadiusM, wM * 0.5, wM, holeBuffers);\n      return {\n        passes: passes,\n        interior: interior,\n        drivable: drivable,\n        boundary: b,\n        turnArcs: serpB.turnArcs,\n        drivePath: serpB.driveCoords,\n        warning: serpB.warning,\n        orderedPasses: serpB.orderedPasses\n      };\n    }\n    \/\/ AB Straight \/ AB Curve \/ Adaptive (synthetic) \u2014 parallel offsets\n    \/\/ clipped to `passClipPoly`.\n    var perpMin = Infinity, perpMax = -Infinity;\n    var alongMin = Infinity, alongMax = -Infinity;\n    for(var i=0; i<passClipPoly.length; i++){\n      var along = passClipPoly[i].x * ux + passClipPoly[i].y * uy;\n      var perp = -passClipPoly[i].x * uy + passClipPoly[i].y * ux;\n      if(perp < perpMin) perpMin = perp;\n      if(perp > perpMax) perpMax = perp;\n      if(along < alongMin) alongMin = along;\n      if(along > alongMax) alongMax = along;\n    }\n    var startPerp = perpMin + wM * 0.5;\n    var endPerp = perpMax - wM * 0.5;\n    \/\/ AB-by-coordinates anchor \u2014 when the user has pinned an AB\n    \/\/ direction via GPS coordinates, snap the pass grid so the line\n    \/\/ through their A point coincides with one of the parallel passes.\n    \/\/ Otherwise the simulator only borrows the BEARING and lays passes\n    \/\/ at the default \u00bd-swath offset, which means the operator's actual\n    \/\/ recorded AB line falls BETWEEN two simulated passes (reported\n    \/\/ 2026-06-03 \u2014 \"\u043e\u043d \u0440\u0430\u0441\u0447\u0438\u0442\u0430\u043b \u0443\u0433\u043e\u043b, \u043d\u043e \u043d\u0435 \u0432\u0437\u044f\u043b \u044d\u0442\u043e\u0442 \u043f\u0440\u043e\u0445\u043e\u0434 \u0437\u0430 \u043e\u0441\u043d\u043e\u0432\u0443\").\n    \/\/ The anchor's perpendicular distance becomes a fixed point on the\n    \/\/ grid; we shift startPerp to the closest multiple of wM from there.\n    if(typeof userAxisAnchor !== 'undefined' ? userAxisAnchor : false){\n      var perpAnchor = -userAxisAnchor.x * uy + userAxisAnchor.y * ux;\n      \/\/ Find k such that perpAnchor + k*wM is the smallest pass \u2265 perpMin + wM*0.5.\n      var k0 = Math.ceil((perpMin + wM * 0.5 - perpAnchor) \/ wM - 1e-6);\n      var aligned = perpAnchor + k0 * wM;\n      if(aligned >= perpMin + wM * 0.5 - 1e-3 ? aligned <= perpMax - wM * 0.5 + 1e-3 : false){\n        startPerp = aligned;\n      }\n    }\n    var alongSpan = alongMax - alongMin;\n    \/\/ EVEN-SPACING GRID FIT (rule \u00a76 coverage floor, 2026-06-11). The old\n    \/\/ grid stepped by exactly wM from perpMin + wM\/2; when the part's\n    \/\/ perpendicular span wasn't an integer multiple of wM, the strip\n    \/\/ between the last pass's swath edge and the far boundary stayed\n    \/\/ UNWORKED \u2014 up to one full implement width along the entire field\n    \/\/ length, PER PART. On a 9-part 207 ha client field at 36 m width\n    \/\/ that summed to ~10 ha of \"missed area\"; the published competitor\n    \/\/ benchmark on the same field shows < 1 ha missed and ~8 ha overlap\n    \/\/ instead. Real planners distribute that remainder as a small UNIFORM\n    \/\/ overlap: fit ceil(span \/ wM) + 1 passes edge-to-edge at spacing\n    \/\/ span \/ n \u2264 wM. Every pass overlaps its neighbour by the same few\n    \/\/ percent (visible in the REPEATS column), nothing is missed, and the\n    \/\/ serpentine sees an even grid (no cramped final pair, which an\n    \/\/ append-a-remainder-pass variant produced \u2014 it doubled crossings on\n    \/\/ L-shape).\n    \/\/\n    \/\/ Exception: when the user pinned the grid via GPS AB coordinates\n    \/\/ (userAxisAnchor), keep the exact-wM stepping \u2014 their recorded pass\n    \/\/ line must stay ON the grid \u2014 and close the far-edge gap with one\n    \/\/ extra pass only when the remainder is meaningful (> 0.3 \u00d7 wM).\n    var ppList = [];\n    var hasAnchorPP = (typeof userAxisAnchor !== 'undefined' ? userAxisAnchor : null) ? true : false;\n    if(endPerp >= startPerp - 1e-6){\n      if(hasAnchorPP){\n        for(var pp0 = startPerp; pp0 <= endPerp + 1e-6; pp0 += wM) ppList.push(pp0);\n        var lastPP = ppList.length > 0 ? ppList[ppList.length - 1] : null;\n        if(lastPP !== null ? endPerp - lastPP > wM * 0.3 : false) ppList.push(endPerp);\n      } else if(endPerp - startPerp < 1e-6){\n        ppList.push(startPerp);\n      } else {\n        var nIv = Math.max(1, Math.ceil((endPerp - startPerp) \/ wM - 1e-6));\n        var ppSpacing = (endPerp - startPerp) \/ nIv;\n        for(var k=0; k<=nIv; k++) ppList.push(startPerp + ppSpacing * k);\n      }\n    } else if(perpMax - perpMin > wM * 0.5){\n      \/\/ Sliver part narrower than one swath: one centred pass covers it\n      \/\/ (previously such parts produced ZERO body passes).\n      ppList.push((perpMin + perpMax) * 0.5);\n    }\n    for(var ppi=0; ppi<ppList.length; ppi++){\n      var pp = ppList[ppi];\n      var p0Along = alongMin - alongSpan * 0.1;\n      var p1Along = alongMax + alongSpan * 0.1;\n      var startX = p0Along * ux - pp * uy;\n      var startY = p0Along * uy + pp * ux;\n      var endX = p1Along * ux - pp * uy;\n      var endY = p1Along * uy + pp * ux;\n      \/\/ Use the multi-segment clipper so concave polygons (L-shape interior!)\n      \/\/ produce one pass per \"inside\" segment when the line crosses a gap.\n      var clippedSegs = clipLineToBoundarySegments(startX, startY, endX, endY, passClipPoly);\n      if(!clippedSegs) continue;\n      \/\/ Subtract obstacle\/hole interiors \u2014 a pass crossing a hole splits\n      \/\/ into before + after sub-passes (operator drives around it).\n      if(holeCutBuffers) clippedSegs = subtractHolesFromSegs(clippedSegs, holeCutBuffers);\n      for(var cs=0; cs<clippedSegs.length; cs++){\n        var clipped = clippedSegs[cs];\n        \/\/ Drop tiny sub-segments produced when a pass clips through a\n        \/\/ concave notch at a sharp angle. Threshold 0.4 \u00d7 wM (was a full\n        \/\/ wM until 2026-06-11): at wide implement settings the old\n        \/\/ threshold discarded every stub shorter than one swath \u2014 on a\n        \/\/ real 207 ha client field at 36 m width that threw away ~3 ha\n        \/\/ of workable interior (\"missed area too big\"). 0.4 \u00d7 wM still\n        \/\/ kills true degenerate slivers (the zero-length test guard) but\n        \/\/ keeps the 15-35 m stubs a real operator absolutely works.\n        var __slx = clipped.x1 - clipped.x0;\n        var __sly = clipped.y1 - clipped.y0;\n        if(__slx * __slx + __sly * __sly < (wM * 0.4) * (wM * 0.4)) continue;\n        \/\/ 'adaptive' (Topography follow) now produces STRAIGHT passes\n        \/\/ along the gradient-perpendicular axis (the axis variable was\n        \/\/ rotated above) \u2014 exactly matching level curves on tilted-plane\n        \/\/ terrains. Only 'ab-curve' uses the synthetic wave amplitude.\n        if(approach === 'ab-curve'){\n          var dxC = clipped.x1 - clipped.x0;\n          var dyC = clipped.y1 - clipped.y0;\n          var lenL = Math.sqrt(dxC*dxC + dyC*dyC);\n          \/\/ Sample density: 1 sample per ~2\u00d7wM along the pass. Cap at 60\n          \/\/ per pass so a 2 km pass on a real-world field stays under\n          \/\/ the budget \u2014 the curve still reads smoothly at typical\n          \/\/ canvas zoom and computeMetrics's per-seg work stays bounded.\n          var nSamp = Math.max(8, Math.min(60, Math.floor(lenL \/ (wM * 2))));\n          var samps = [];\n          \/\/ PHASE-LOCKED PARALLEL CURVES (rewritten 2026-06-10). Real\n          \/\/ AB-Curve is ONE recorded curved baseline; every pass is a\n          \/\/ perpendicular offset of that same baseline, so all passes\n          \/\/ wave IN UNISON and tile at exact wM spacing. The previous\n          \/\/ synthesis gave each pass its own phase (`(pp\u2212perpMin)\/wM`)\n          \/\/ and normalised the wave to each pass's own clipped length \u2014\n          \/\/ adjacent passes waved out of sync, leaving 0.6 \u00d7 wM gaps on\n          \/\/ one flank and 0.6 \u00d7 wM overlap on the other. Result: ~82 %\n          \/\/ coverage + ~20 % overlap on EVERY field (worst Compare-All\n          \/\/ row across the board, baseline 2026-06-10).\n          \/\/\n          \/\/ New model: wave = A \u00d7 sin(2\u03c0 \u00d7 s \/ \u03bb) where s is the sample's\n          \/\/ ABSOLUTE along-axis coordinate \u2014 identical for every pass, so\n          \/\/ vertical slices through the field show all passes displaced\n          \/\/ by the same amount = constant wM gap everywhere. (A fixed-\n          \/\/ perpendicular offset of a sine is not the exact mathematical\n          \/\/ parallel curve, but at A = 0.6 \u00d7 wM and \u03bb = 12 \u00d7 wM the\n          \/\/ curvature error is < 3 % of wM \u2014 invisible next to the 60 %\n          \/\/ tiling error of the out-of-phase model.)\n          var amplitude = wM * 0.6;\n          var wavelength = wM * 12;\n          for(var ss=0; ss<=nSamp; ss++){\n            var t = nSamp > 0 ? ss \/ nSamp : 0;\n            var bxs = clipped.x0 + dxC * t;\n            var bys = clipped.y0 + dyC * t;\n            var alongS = bxs * ux + bys * uy;\n            var wave = Math.sin(alongS \/ wavelength * Math.PI * 2) * amplitude;\n            var wxs = bxs + -uy * wave;\n            var wys = bys + ux * wave;\n            \/\/ The wave displacement can nudge a sample into a (buffered)\n            \/\/ hole near its edge even though the clipped centreline cleared\n            \/\/ it. Drop the wave for that sample \u2014 keep the on-axis point so\n            \/\/ the curve never enters a no-go zone (rule \u00a76).\n            if(holeBuffers ? pointInHoles(wxs, wys, holeBuffers) : false){\n              samps.push({ x: bxs, y: bys });\n            } else {\n              samps.push({ x: wxs, y: wys });\n            }\n          }\n          passes.push({ samples: samps, kind: approach });\n        } else {\n          passes.push({ x0: clipped.x0, y0: clipped.y0, x1: clipped.x1, y1: clipped.y1, kind: approach });\n        }\n      }\n    }\n    \/\/ Universal gap-fill (rule \u00a76 coverage floor) \u2014 patch boundary-band\n    \/\/ wedges, sliver stubs, and hole-adjacent pockets the primary grid\n    \/\/ missed. Skipped for suppressHeadland sub-blocks: their neighbours'\n    \/\/ passes aren't visible in this call, so cells along the OPEN cut\n    \/\/ edge would read as \"uncovered\" and get double-filled.\n    if(!skipHeadland){\n      var gapFills = buildFillPasses(passes, b, bRef, { ux: ux, uy: uy }, wM, fieldHoles, holeCutBuffers);\n      for(var gf=0; gf<gapFills.length; gf++) passes.push(gapFills[gf]);\n    }\n    var serp = buildSerpentine(passes, drivable, interior, axis, turnStyle, turnRadiusM, wM * 0.5, wM, holeBuffers);\n    return {\n      passes: passes,\n      interior: interior,\n      drivable: drivable,\n      boundary: b,\n      turnArcs: serp.turnArcs,\n      drivePath: serp.driveCoords,\n      warning: serp.warning,\n      orderedPasses: serp.orderedPasses\n    };\n  }\n  \/\/ Multi-part wrapper: build a sub-layout per polygon and join them with a\n  \/\/ \"transport\" leg so the operator visibly finishes one part BEFORE starting\n  \/\/ the next. Real ag rule \u2014 disconnected field parts (forest \/ river \/ road\n  \/\/ in between) can't be worked simultaneously; the operator drives the road\n  \/\/ to the next part. Transport legs are NOT worked (no swath, no metrics).\n  function generateLinesAll(approach, wM, parts, axis, headlandM, turnStyle, turnRadiusM, outsideBufferM){\n    \/\/ Convex decomposition for Boundary Follow on a SINGLE concave\n    \/\/ polygon: split at reflex vertices into convex sub-parts that\n    \/\/ can each be ring-spiralled independently. The transport leg\n    \/\/ between sub-parts routes via the CUT EDGE MIDPOINT (always\n    \/\/ inside the original polygon by construction \u2014 the cut is an\n    \/\/ interior chord) so the machine never leaves the field. Per\n    \/\/ rule \u00a76, this is the only way to fill an L-shape's centre\n    \/\/ without exiting the boundary.\n    \/\/\n    \/\/ Decomposition fires only when:\n    \/\/   \u2022 approach === 'boundary' (no other approach benefits)\n    \/\/   \u2022 input is a SINGLE polygon (multi-polygon uploads handle\n    \/\/     their own inter-part transport via the GENUINE road hop\n    \/\/     between disconnected fields)\n    \/\/   \u2022 the polygon actually has a reflex vertex\n    var decomposedFromSingle = false;\n    var originalParts = parts;\n    \/\/ Approach 'auto-blocks': split a single concave polygon into convex\n    \/\/ sub-blocks AND use a per-block heading (each block's own PCA axis)\n    \/\/ so different regions of an irregular field get worked at the\n    \/\/ orientation that fits THEM, not a global \"field average\" angle.\n    \/\/ Multi-part inputs (multiple disconnected polygons in one file \u2014\n    \/\/ common with shapefiles representing one operational field split\n    \/\/ across a creek\/road) already get per-part axes via the per-part\n    \/\/ loop below; auto-blocks just adds the same per-part logic to\n    \/\/ single-polygon concave fields via convex decomposition.\n    var isAutoBlocks = approach === 'auto-blocks';\n    var isAdaptive = approach === 'adaptive';\n    \/\/ Topography Follow + Auto-blocks decompose concave polygons into\n    \/\/ per-block sub-regions. AB-Straight + AB-Curve stay SINGLE-AXIS by\n    \/\/ design \u2014 that's their identity as the na\u00efve baseline \"one\n    \/\/ direction across the whole field\". Reported 2026-06-04: when\n    \/\/ AB-Straight was decomposed it became visually indistinguishable\n    \/\/ from Auto-blocks; the Compare All differentiation collapsed.\n    \/\/ Long-route transitions on AB-Straight across concave notches are\n    \/\/ handled by the serpentine's cluster-aware shortcut routing\n    \/\/ (interior chord with snap-to-boundary fallback).\n    var useDecomposition = isAutoBlocks ? true : isAdaptive;\n    \/\/ V1 obstacle limitation \u2014 a part that carries holes is NOT decomposed.\n    \/\/ decomposeIntoBlocks produces fresh sub-polygons that don't inherit\n    \/\/ the parent's holes, so a sub-block could re-cover an obstacle. Until\n    \/\/ hole-aware decomposition lands, fields with holes work as a single\n    \/\/ block (still hole-aware via the body-pass subtract + arc avoidance).\n    var anyPartHasHoles = false;\n    if(parts){\n      for(var phh=0; phh<parts.length; phh++){ if(parts[phh].holes ? parts[phh].holes.length > 0 : false){ anyPartHasHoles = true; break; } }\n    }\n    if(anyPartHasHoles) useDecomposition = false;\n    if(useDecomposition ? parts ? parts.length >= 1 : false : false){\n      \/\/ decomposeIntoBlocks each input part. Multi-part inputs (e.g.\n      \/\/ 2-direction sample with 2 polygons) often have ONE big polygon\n      \/\/ that ITSELF has natural sub-blocks. Pre-fix only decomposed\n      \/\/ single-polygon inputs, which left those big multi-arm polygons\n      \/\/ as single blocks \u2192 many crossings inside them. Now every input\n      \/\/ part gets recursive reflex-cut decomposition.\n      \/\/ Multi-part inputs (e.g. Kurylivka with 7 disconnected polygons,\n      \/\/ Kryva Ruda with 3) are ALREADY operationally separate \u2014 each\n      \/\/ polygon is its own field arm in the operator's eyes, so each\n      \/\/ becomes one block without further sub-decomposition. The global\n      \/\/ block cap only restricts further splits of a SINGLE polygon.\n      \/\/\n      \/\/ Single-polygon inputs (3-direction arm field, 2-direction\n      \/\/ tongue field) get the conservative recursive split:\n      \/\/   minSubArea  = 5 ha (50000 m\u00b2)\n      \/\/   maxBlocks   = 3 \u2014 overall ceiling\n      \/\/   reflex gate = 65\u00b0 + orientation-gain \u2265 25\u00b0 (decomposeIntoBlocks)\n      var anyDecomposed = false;\n      if(parts.length === 1){\n        \/\/ Auto-blocks NEW MODEL (rule \u00a76 refinement, 2026-06-04):\n        \/\/ Detected sub-blocks are PLANNING ZONES for different pass\n        \/\/ directions, NOT separate sub-fields. There must be ONE outer\n        \/\/ headland for the original polygon \u2014 not per-block headlands.\n        \/\/ The internal cut edge between sub-blocks is OPEN: body passes\n        \/\/ are allowed to meet there.\n        \/\/\n        \/\/ Mechanics: decompose the INNER WORKABLE polygon (= original\n        \/\/ minus outer headland strip), not the original polygon. Each\n        \/\/ sub-block then sits ENTIRELY inside the inner workable area,\n        \/\/ so body passes naturally avoid the outer headland zone. The\n        \/\/ outer headland ring is added once, separately, at the end.\n        \/\/ Sub-blocks call generateLines with suppressHeadland=true to\n        \/\/ skip the per-block ring + pullBack.\n        var innerForDecomp = parts[0];\n        if(headlandM > 0){\n          var iw = offsetPolygonInward(parts[0], headlandM);\n          if(iw ? polygonSelfIntersects(iw) : false){\n            var iwRep = repairSelfIntersections(iw);\n            if(iwRep ? iwRep.length >= 4 : false) iw = iwRep;\n          }\n          if(iw ? iw.length >= 4 : false) innerForDecomp = iw;\n        }\n        var subsHere = decomposeIntoBlocks(innerForDecomp, 60000, 3);  \/\/ min sub-block 6 ha \u2014 slim L-shape arms qualify\n        if(subsHere.length > 1){\n          parts = subsHere;\n          decomposedFromSingle = true;\n          anyDecomposed = true;\n        }\n      }\n      \/\/ Multi-part inputs fall through: parts stays as-is, each part\n      \/\/ becomes one block with its own per-block PCA axis below.\n    } else if(approach === 'boundary' ? (parts ? (parts.length === 1 ? !anyPartHasHoles : false) : false) : false){\n      \/\/ Boundary Follow stays SINGLE-POLYGON on concave fields. The\n      \/\/ earlier decomposeIntoBlocks experiment (2026-06-04) split fields\n      \/\/ at any 80\u00b0+ reflex, which on real-world boundaries with mild\n      \/\/ concave corners (uploaded sample_field_GP \u2014 82\u00b0 reflex on a\n      \/\/ mostly rectangular field) cut a 22 ha wedge off the main body\n      \/\/ and dropped boundary coverage from 93 % \u2192 80 %. The drive-\n      \/\/ ordering issue on synthetic L-shape (ring jumps between arms)\n      \/\/ is acknowledged as a known limitation \u2014 concentric rings on a\n      \/\/ concave polygon can't both order cleanly AND wrap fully into\n      \/\/ corners. Coverage wins. The Compare-All BEST gate routes users\n      \/\/ away from Boundary Follow on L-shape anyway.\n      var subs = decomposeIntoConvex(parts[0]);\n      if(subs.length > 1){\n        parts = subs;\n        decomposedFromSingle = true;\n      }\n    }\n    if(!parts ? true : parts.length <= 1){\n      \/\/ Single block \u2014 auto-blocks degenerates to AB Straight at the\n      \/\/ (single) part's own PCA axis. For other approaches, pass the\n      \/\/ user-supplied axis through unchanged.\n      var effAppr = isAutoBlocks ? 'ab-straight' : approach;\n      var effAxis = isAutoBlocks ? fieldAxis(parts[0]) : axis;\n      var single = generateLines(effAppr, wM, parts[0], effAxis, headlandM, turnStyle, turnRadiusM, outsideBufferM);\n      single.boundaryParts = parts;\n      single.interiors = single.interior ? [single.interior] : [];\n      return single;\n    }\n    \/\/ Order parts: start with the LARGEST part (the operator's primary\n    \/\/ workload \u2014 gets shown first in playback), then visit remaining\n    \/\/ parts in NEAREST-NEIGHBOUR order from the previous part's centroid.\n    \/\/ Largest-first ALONE produced long diagonal transport hops when the\n    \/\/ file had a small SW polygon (Demo_US sample) \u2014 the machine would\n    \/\/ finish the big field at its NE corner and trek diagonally to the\n    \/\/ SW polygon, spending kilometers outside any field. Nearest-\n    \/\/ neighbour after the first part minimises that out-of-field travel.\n    var ordered = [];\n    var remaining = parts.slice();\n    if(remaining.length > 0){\n      var largestI = 0, largestA = -Infinity;\n      for(var pli=0; pli<remaining.length; pli++){\n        var pla = polyArea(remaining[pli]);\n        if(pla > largestA){ largestA = pla; largestI = pli; }\n      }\n      ordered.push(remaining[largestI]);\n      remaining.splice(largestI, 1);\n    }\n    while(remaining.length > 0){\n      var prevPart = ordered[ordered.length - 1];\n      var pcx = 0, pcy = 0;\n      for(var pci=0; pci<prevPart.length; pci++){ pcx += prevPart[pci].x; pcy += prevPart[pci].y; }\n      pcx \/= prevPart.length; pcy \/= prevPart.length;\n      var bestI = 0, bestD = Infinity;\n      for(var ri=0; ri<remaining.length; ri++){\n        var rp = remaining[ri];\n        var rcx = 0, rcy = 0;\n        for(var rci=0; rci<rp.length; rci++){ rcx += rp[rci].x; rcy += rp[rci].y; }\n        rcx \/= rp.length; rcy \/= rp.length;\n        var ddx = rcx - pcx, ddy = rcy - pcy;\n        var dd = ddx * ddx + ddy * ddy;\n        if(dd < bestD){ bestD = dd; bestI = ri; }\n      }\n      ordered.push(remaining[bestI]);\n      remaining.splice(bestI, 1);\n    }\n    var allPasses = [];\n    var allArcs = [];\n    var allDrive = [];\n    var interiors = [];\n    var warnings = [];\n    \/\/ Auto-blocks new model: emit ONE outer headland ring for the original\n    \/\/ polygon before the per-block body loop. The sub-blocks have already\n    \/\/ been decomposed INSIDE the inner workable polygon, so they don't\n    \/\/ need their own per-block headland. The cut edges between sub-blocks\n    \/\/ are OPEN \u2014 body passes meet there.\n    var outerRingEmitted = false;\n    if(useDecomposition ? decomposedFromSingle : false){\n      if(headlandM > 0){\n        var nOuterRings = Math.max(1, Math.round(headlandM \/ wM));\n        for(var rkO=0; rkO<nOuterRings; rkO++){\n          var ringOffO = (rkO + 0.5) * wM;\n          var ringRefO = offsetPolygonInward(originalParts[0], ringOffO);\n          if(ringRefO ? ringRefO.length >= 4 : false){\n            if(polygonSelfIntersects(ringRefO)){\n              var repO = repairSelfIntersections(ringRefO);\n              if(repO ? repO.length >= 4 : false) ringRefO = repO;\n              else continue;\n            }\n            \/\/ Re-validate after repair \u2014 see same guard in generateLines\n            \/\/ for the rationale (multi-V W-shape boundaries).\n            if(polygonSelfIntersects(ringRefO)) continue;\n            var ringSmoothO = roundPolygonCorners(ringRefO, turnRadiusM);\n            \/\/ Final guard \u2014 corner rounding can re-introduce crossings.\n            if(polygonSelfIntersects(ringSmoothO.concat([ringSmoothO[0]]))) ringSmoothO = ringRefO;\n            if(polygonSelfIntersects(ringSmoothO.concat([ringSmoothO[0]]))) continue;\n            var ringSamplesO = ringSmoothO.concat([ringSmoothO[0]]);\n            var ringPassO = { samples: ringSamplesO, kind: 'headland-ring', partIdx: -1 };\n            allPasses.push(ringPassO);\n            \/\/ Drive along the ring perimeter \u2014 closed loop, no transport\n            \/\/ tagging (this IS worked ground).\n            for(var rso=0; rso<ringSamplesO.length; rso++){\n              allDrive.push({ x: ringSamplesO[rso].x, y: ringSamplesO[rso].y });\n            }\n            outerRingEmitted = true;\n          }\n        }\n      }\n    }\n    \/\/ Auto-blocks: build a list of { key, areaHa, axisDeg, isOverride }\n    \/\/ descriptors for the UI's per-block angle picker. The key is the\n    \/\/ block centroid rounded to 5 m \u2014 stable across recomputes as long\n    \/\/ as the polygon doesn't change, so the user's per-block angle\n    \/\/ overrides survive wM tweaks and approach toggles.\n    var blockDescriptors = [];\n    for(var pi=0; pi<ordered.length; pi++){\n      \/\/ Auto-blocks: each block gets its OWN PCA axis from its own\n      \/\/ geometry \u2014 different parts of an irregular field get the\n      \/\/ orientation that fits THEM. Other approaches keep the global\n      \/\/ axis (single field-wide heading) to preserve the existing\n      \/\/ behaviour where the user can pick a heading and have it apply\n      \/\/ uniformly across all parts.\n      \/\/ Per-block angle override (user-set via the block list UI):\n      \/\/ look up by the block's centroid key. Falls back to PCA axis\n      \/\/ when no override is set.\n      var bk = blockCentroidKey(ordered[pi]);\n      var ovrDeg = isAutoBlocks ? BLOCK_AXIS_OVERRIDES[bk] : undefined;\n      var perPartAxis;\n      var perPartDeg;\n      if(isAutoBlocks){\n        if(typeof ovrDeg === 'number'){\n          var rOv = ovrDeg * Math.PI \/ 180;\n          perPartAxis = { ux: Math.cos(rOv), uy: Math.sin(rOv) };\n          perPartDeg = ovrDeg;\n        } else {\n          perPartAxis = fieldAxis(ordered[pi]);\n          var pAd = Math.round(Math.atan2(perPartAxis.uy, perPartAxis.ux) * 180 \/ Math.PI);\n          while(pAd < 0) pAd += 180;\n          while(pAd >= 180) pAd -= 180;\n          perPartDeg = pAd;\n        }\n      } else if(isAdaptive ? decomposedFromSingle : false){\n        \/\/ Topography decomposed mode: per-block PCA axis is a placeholder.\n        \/\/ The actual gradient-perpendicular rotation happens inside\n        \/\/ generateLines's `adaptive` branch, which samples terrain across\n        \/\/ the sub-block and re-orients passes along its level curves.\n        perPartAxis = fieldAxis(ordered[pi]);\n        perPartDeg = null;\n      } else {\n        perPartAxis = axis;\n        perPartDeg = null;\n      }\n      if(isAutoBlocks){\n        blockDescriptors.push({\n          key: bk,\n          areaHa: polyArea(ordered[pi]) \/ 10000,\n          axisDeg: perPartDeg,\n          isOverride: typeof ovrDeg === 'number'\n        });\n      }\n      var perPartAppr;\n      if(isAutoBlocks) perPartAppr = 'ab-straight';\n      else if(isAdaptive ? decomposedFromSingle : false) perPartAppr = 'adaptive';\n      else perPartAppr = approach;\n      \/\/ Decomposed sub-blocks (auto-blocks OR Topography on concave):\n      \/\/ skip per-block headland ring + inward offset because the outer\n      \/\/ ring already lives on the ORIGINAL polygon (emitted once above)\n      \/\/ and internal cut edges must stay OPEN so body passes meet at\n      \/\/ the split line.\n      var suppressBlockHeadland = useDecomposition ? decomposedFromSingle : false;\n      var effHeadlandM = suppressBlockHeadland ? 0 : headlandM;\n      var sub = generateLines(perPartAppr, wM, ordered[pi], perPartAxis, effHeadlandM, turnStyle, turnRadiusM, outsideBufferM, suppressBlockHeadland);\n      \/\/ Tag passes + arcs with their part index so renderer can group them\n      \/\/ (e.g. \"Part 2 \/ 3\" status label, separate compaction zones).\n      for(var p=0; p<sub.passes.length; p++){\n        sub.passes[p].partIdx = pi;\n        allPasses.push(sub.passes[p]);\n      }\n      if(sub.turnArcs){\n        for(var a=0; a<sub.turnArcs.length; a++){\n          sub.turnArcs[a].partIdx = pi;\n          allArcs.push(sub.turnArcs[a]);\n        }\n      }\n      if(sub.interior) interiors.push(sub.interior);\n      if(sub.warning) warnings.push('Part ' + (pi + 1) + ': ' + sub.warning);\n      \/\/ Insert a \"transport\" leg between previous sub's last drive point and\n      \/\/ this sub's first drive point. The transport leg appears in:\n      \/\/   \u2022 turnArcs (rendered as gray dashed straight line)\n      \/\/   \u2022 drivePath (sampled 20 pts so playback animates the hop, with\n      \/\/     transport:true so drawSwath skips it \u2014 no work happens here)\n      \/\/ Transport leg before this sub-block. Fires whenever there's an\n      \/\/ earlier drive segment (previous block OR the outer ring from\n      \/\/ the auto-blocks new model). pi === 0 with outerRingEmitted=true\n      \/\/ means we need a hop from the ring's closing vertex to block 1's\n      \/\/ body entry.\n      var needTransport = pi > 0 ? allDrive.length > 0 : (outerRingEmitted ? allDrive.length > 0 : false);\n      if(needTransport){\n        var firstP = sub.drivePath ? (sub.drivePath.length > 0 ? sub.drivePath[0] : null) : null;\n        if(firstP){\n          var lastP = allDrive[allDrive.length - 1];\n          \/\/ When this sub-part comes from internal convex decomposition\n          \/\/ (cutEdge metadata attached by decomposeIntoBlocks or\n          \/\/ decomposeIntoConvex), route the transport leg via BOTH\n          \/\/ cut endpoints so the hop stays inside the original polygon.\n          \/\/ The cut chord is by construction interior, so we route:\n          \/\/   lastP \u2192 nearest cut endpoint (inside polyA, convex)\n          \/\/         \u2192 far cut endpoint (along the chord, interior)\n          \/\/         \u2192 firstP (inside polyB, convex)\n          \/\/ Routing via the midpoint alone fails when lastP \/ firstP\n          \/\/ sit far from the chord's midpoint \u2014 the straight line to the\n          \/\/ midpoint can clip the concave notch (rule \u00a76, reported\n          \/\/ 2026-06-04 on lshape\/auto-blocks where 4 transport points\n          \/\/ landed outside the L).\n          \/\/\n          \/\/ Genuine multi-polygon uploads have no cutEdge and use the\n          \/\/ straight-line hop (the operator really does drive the road\n          \/\/ between fields).\n          \/\/ pi === 0 (outer-ring \u2192 block 1 hop) has no cut edge \u2014 there's\n          \/\/ no prior block to share a chord with. Use straight-line hop\n          \/\/ through the original polygon (sample snap-to-boundary keeps\n          \/\/ it inside the field).\n          var cutE = decomposedFromSingle ? (pi > 0 ? (ordered[pi].cutEdge ? ordered[pi].cutEdge : ordered[pi-1].cutEdge) : null) : null;\n          var waypoints;\n          if(cutE){\n            var d1cL = (lastP.x - cutE.x1) * (lastP.x - cutE.x1) + (lastP.y - cutE.y1) * (lastP.y - cutE.y1);\n            var d2cL = (lastP.x - cutE.x2) * (lastP.x - cutE.x2) + (lastP.y - cutE.y2) * (lastP.y - cutE.y2);\n            var cutNearL = d1cL < d2cL ? { x: cutE.x1, y: cutE.y1 } : { x: cutE.x2, y: cutE.y2 };\n            var cutNearF = d1cL < d2cL ? { x: cutE.x2, y: cutE.y2 } : { x: cutE.x1, y: cutE.y1 };\n            waypoints = [\n              { x: lastP.x, y: lastP.y },\n              cutNearL,\n              cutNearF,\n              { x: firstP.x, y: firstP.y }\n            ];\n          } else {\n            waypoints = [\n              { x: lastP.x, y: lastP.y },\n              { x: firstP.x, y: firstP.y }\n            ];\n          }\n          allArcs.push({\n            coords: waypoints.slice(),\n            kind: 'transport',\n            ok: true,\n            partIdx: pi,\n            transport: true\n          });\n          \/\/ Sample 10 points between each consecutive waypoint pair so\n          \/\/ playback animates smoothly. Each sampled coord carries\n          \/\/ transport:true so drawSwath skips it (rule \u00a76 \u2014 no work).\n          \/\/\n          \/\/ For single-polygon decompositions (auto-blocks on L-shape, T,\n          \/\/ U), the sub-block polygons can be subtly concave so a\n          \/\/ straight line between waypoints can clip the original\n          \/\/ polygon's boundary. We snap any sample that falls OUTSIDE\n          \/\/ the original polygon back to its nearest boundary edge so\n          \/\/ the visible transport line hugs the field edge instead of\n          \/\/ cutting through the void.\n          var origPoly = decomposedFromSingle ? originalParts[0] : null;\n          for(var wpi=1; wpi<waypoints.length; wpi++){\n            var wA = waypoints[wpi-1], wB = waypoints[wpi];\n            var nT = 10;\n            for(var ts=1; ts<=nT; ts++){\n              var tt = ts \/ nT;\n              var sx = wA.x + (wB.x - wA.x) * tt;\n              var sy = wA.y + (wB.y - wA.y) * tt;\n              if(origPoly ? !pointInPoly(sx, sy, origPoly) : false){\n                var snap = nearestPointOnPolygonBoundary(sx, sy, origPoly);\n                if(snap){ sx = snap.x; sy = snap.y; }\n              }\n              allDrive.push({ x: sx, y: sy, transport: true });\n            }\n          }\n        }\n      }\n      if(sub.drivePath){\n        for(var dp=0; dp<sub.drivePath.length; dp++) allDrive.push(sub.drivePath[dp]);\n      }\n    }\n    \/\/ When decomposition fired (single concave polygon \u2192 multiple\n    \/\/ convex sub-parts), the user-facing boundary is still the\n    \/\/ ORIGINAL polygon, not the two split pieces. Renderer + metrics\n    \/\/ get the original; the decomposed parts only fed the internal\n    \/\/ pass-generation pipeline.\n    var pubBoundary = decomposedFromSingle ? originalParts[0] : ordered[0];\n    var pubBoundaryParts = decomposedFromSingle ? originalParts : ordered;\n    return {\n      passes: allPasses,\n      interior: interiors[0] || ordered[0],\n      interiors: interiors,\n      drivable: pubBoundary,\n      boundary: pubBoundary,\n      boundaryParts: pubBoundaryParts,\n      blocks: isAutoBlocks ? blockDescriptors : null,\n      blockPolys: isAutoBlocks ? ordered : null,\n      turnArcs: allArcs,\n      drivePath: allDrive,\n      warning: warnings.length ? warnings.join(' \u00b7 ') : null,\n      orderedPasses: allPasses\n    };\n  }\n  \/\/ Rasterise the layout's swath onto a grid covering the field bbox. For\n  \/\/ each cell whose centre is inside the boundary AND within wM\/2 of any\n  \/\/ pass-centerline or ring-centerline segment, mark it covered. Returns\n  \/\/ { coveredM2, fieldM2, pct }. cellSize defaults to ~wM\/3 so cells are\n  \/\/ smaller than the swath \u2014 under-fill near corners is captured honestly.\n  function rasterCoverage(layout, wM){\n    \/\/ Multi-part: union the cells in ANY part. Single-part: just the boundary.\n    var parts = layout.boundaryParts ? (layout.boundaryParts.length ? layout.boundaryParts : [layout.boundary ? layout.boundary : BOUNDARY]) : [layout.boundary ? layout.boundary : BOUNDARY];\n    \/\/ Combined bbox across all parts (so cellgrid covers them all).\n    var minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;\n    for(var pp=0; pp<parts.length; pp++){\n      for(var pv=0; pv<parts[pp].length; pv++){\n        var px0 = parts[pp][pv].x, py0 = parts[pp][pv].y;\n        if(px0 < minX) minX = px0; if(px0 > maxX) maxX = px0;\n        if(py0 < minY) minY = py0; if(py0 > maxY) maxY = py0;\n      }\n    }\n    var stats = { minX: minX, maxX: maxX, minY: minY, maxY: maxY };\n    var cellSize = Math.max(2, wM \/ 3);\n    var nx = Math.max(8, Math.ceil((stats.maxX - stats.minX) \/ cellSize));\n    var ny = Math.max(8, Math.ceil((stats.maxY - stats.minY) \/ cellSize));\n    \/\/ First pass: mark cells inside ANY field part\n    var inField = new Uint8Array(nx * ny);\n    var fieldCellCount = 0;\n    for(var iy=0; iy<ny; iy++){\n      for(var ix=0; ix<nx; ix++){\n        var cx = stats.minX + (ix + 0.5) * cellSize;\n        var cy = stats.minY + (iy + 0.5) * cellSize;\n        var inAny = false;\n        for(var ip=0; ip<parts.length; ip++){\n          if(pointInPoly(cx, cy, parts[ip])){\n            \/\/ Obstacle\/hole cells are NOT part of the workable field \u2014\n            \/\/ they must not count toward field area (else \"missed area\"\n            \/\/ is inflated) nor toward coverage. Exclude them here.\n            if(parts[ip].holes ? pointInHoles(cx, cy, parts[ip].holes) : false) break;\n            inAny = true; break;\n          }\n        }\n        if(inAny){\n          inField[iy * nx + ix] = 1;\n          fieldCellCount++;\n        }\n      }\n    }\n    \/\/ Collect all swath segments \u2014 body-pass segments and ring segments.\n    var segs = [];\n    if(layout.passes){\n      for(var pi=0; pi<layout.passes.length; pi++){\n        var pa = layout.passes[pi];\n        if(pa.samples){\n          for(var si=1; si<pa.samples.length; si++){\n            segs.push({ x0: pa.samples[si-1].x, y0: pa.samples[si-1].y, x1: pa.samples[si].x, y1: pa.samples[si].y });\n          }\n        } else if(pa.x0 !== undefined){\n          segs.push({ x0: pa.x0, y0: pa.y0, x1: pa.x1, y1: pa.y1 });\n        }\n      }\n    }\n    if(segs.length === 0){\n      return { coveredM2: 0, fieldM2: fieldCellCount * cellSize * cellSize, pct: 0, overlapM2: 0, overlapPct: 0 };\n    }\n    \/\/ Inverted-loop coverage count \u2014 for each segment, iterate ONLY the\n    \/\/ grid cells inside its bounding box (expanded by halfW). The old\n    \/\/ implementation was for-each-cell \u00d7 for-each-segment, which is\n    \/\/ O(cells \u00d7 segs) and breaks down on big AB-Curve fields with\n    \/\/ dense sample arrays (a 460 ha field with 18 m swath \u21d2 ~100k cells\n    \/\/ \u00d7 ~35k sample segs = 3.5 B distance checks \u21d2 1.1 s per recompute).\n    \/\/ The inverted loop only touches cells near each segment (a 10 m\n    \/\/ segment with half-width 9 m covers ~4-9 cells) \u21d2 < 50 ms even\n    \/\/ for the worst-case AB-Curve case. Same hit-counting semantics.\n    var half = wM * 0.5;\n    var half2 = half * half;\n    var hitsArr = new Uint8Array(nx * ny);\n    for(var sg=0; sg<segs.length; sg++){\n      var s = segs[sg];\n      var sdx = s.x1 - s.x0, sdy = s.y1 - s.y0;\n      var L2 = sdx*sdx + sdy*sdy;\n      if(L2 < 1e-9) continue;\n      var sBx0 = Math.min(s.x0, s.x1) - half;\n      var sBy0 = Math.min(s.y0, s.y1) - half;\n      var sBx1 = Math.max(s.x0, s.x1) + half;\n      var sBy1 = Math.max(s.y0, s.y1) + half;\n      var ixA = Math.max(0,    Math.floor((sBx0 - stats.minX) \/ cellSize));\n      var ixB = Math.min(nx-1, Math.floor((sBx1 - stats.minX) \/ cellSize));\n      var iyA = Math.max(0,    Math.floor((sBy0 - stats.minY) \/ cellSize));\n      var iyB = Math.min(ny-1, Math.floor((sBy1 - stats.minY) \/ cellSize));\n      for(var iyS=iyA; iyS<=iyB; iyS++){\n        for(var ixS=ixA; ixS<=ixB; ixS++){\n          var idxS = iyS * nx + ixS;\n          if(!inField[idxS]) continue;\n          if(hitsArr[idxS] >= 2) continue;  \/\/ already overlap, no need to recount\n          var cxS = stats.minX + (ixS + 0.5) * cellSize;\n          var cyS = stats.minY + (iyS + 0.5) * cellSize;\n          var t = ((cxS - s.x0) * sdx + (cyS - s.y0) * sdy) \/ L2;\n          if(t < 0 ? true : t > 1) continue;\n          var pxD = s.x0 + sdx * t - cxS;\n          var pyD = s.y0 + sdy * t - cyS;\n          if(pxD * pxD + pyD * pyD <= half2){\n            if(hitsArr[idxS] < 2) hitsArr[idxS]++;\n          }\n        }\n      }\n    }\n    var hits = hitsArr;\n    var coveredCount = 0, overlapCount = 0;\n    var uncoveredIdx = [];\n    for(var hi=0; hi<hits.length; hi++){\n      if(!inField[hi]) continue;\n      if(hits[hi] >= 1) coveredCount++;\n      else uncoveredIdx.push(hi);\n      if(hits[hi] >= 2) overlapCount++;\n    }\n    \/\/ FRACTIONAL EDGE ACCOUNTING (2026-06-11 \u2014 \"missed area too big\").\n    \/\/ The whole-cell test marks a cell covered only when its CENTRE is\n    \/\/ within wM\/2 of a centreline, so every boundary cell whose centre\n    \/\/ sits just past a swath edge counts as 100 % missed even when 70 %\n    \/\/ of the cell is painted. The bias \u2248 perimeter \u00d7 cellSize\/2 \u2014 on a\n    \/\/ 6 km-perimeter client field that overstated \"missed area\" by\n    \/\/ ~3.5 ha while the actual plan was near-complete (the published\n    \/\/ competitor benchmarks use polygon-boolean accounting and report\n    \/\/ the same plans at < 1 % missed). Fix: only for the UNCOVERED\n    \/\/ cells (a few hundred at most), subsample 3 \u00d7 3 and credit the\n    \/\/ fraction of subpoints that fall inside a swath. Bounded cost:\n    \/\/ uncovered cells \u00d7 9 subpoints \u00d7 segments.\n    var fracCovered = 0;\n    if(uncoveredIdx.length > 0 ? segs.length > 0 : false){\n      var subOff = [ -cellSize \/ 3, 0, cellSize \/ 3 ];\n      \/\/ Pre-extract seg bboxes once; per cell build a SHORTLIST of segs\n      \/\/ whose padded bbox touches the cell, then run the 9 subpoints\n      \/\/ against just that shortlist (usually 0-3 segs). The naive\n      \/\/ per-subpoint full scan cost 9 \u00d7 segs per cell and pushed a\n      \/\/ 200 ha Boundary-Follow recompute to ~2.6 s.\n      var segBB = new Float64Array(segs.length * 4);\n      for(var sb=0; sb<segs.length; sb++){\n        var sbs = segs[sb];\n        segBB[sb*4]   = Math.min(sbs.x0, sbs.x1) - half;\n        segBB[sb*4+1] = Math.max(sbs.x0, sbs.x1) + half;\n        segBB[sb*4+2] = Math.min(sbs.y0, sbs.y1) - half;\n        segBB[sb*4+3] = Math.max(sbs.y0, sbs.y1) + half;\n      }\n      var padC = cellSize * 0.5;\n      var shortlist = [];\n      for(var ui=0; ui<uncoveredIdx.length; ui++){\n        var uIdx = uncoveredIdx[ui];\n        var uIy = Math.floor(uIdx \/ nx);\n        var uIx = uIdx - uIy * nx;\n        var ucx = stats.minX + (uIx + 0.5) * cellSize;\n        var ucy = stats.minY + (uIy + 0.5) * cellSize;\n        shortlist.length = 0;\n        for(var sb2=0; sb2<segs.length; sb2++){\n          if(ucx < segBB[sb2*4] - padC ? true : ucx > segBB[sb2*4+1] + padC) continue;\n          if(ucy < segBB[sb2*4+2] - padC ? true : ucy > segBB[sb2*4+3] + padC) continue;\n          shortlist.push(segs[sb2]);\n        }\n        if(shortlist.length === 0) continue;\n        var subCov = 0;\n        for(var syi=0; syi<3; syi++){\n          for(var sxi=0; sxi<3; sxi++){\n            var spx = ucx + subOff[sxi];\n            var spy = ucy + subOff[syi];\n            var hit = false;\n            for(var usg=0; usg<shortlist.length; usg++){\n              var us = shortlist[usg];\n              var udx = us.x1 - us.x0, udy = us.y1 - us.y0;\n              var uL2 = udx*udx + udy*udy;\n              if(uL2 < 1e-9) continue;\n              var ut = ((spx - us.x0) * udx + (spy - us.y0) * udy) \/ uL2;\n              if(ut < 0) ut = 0; else if(ut > 1) ut = 1;\n              var upx = us.x0 + udx * ut - spx;\n              var upy = us.y0 + udy * ut - spy;\n              if(upx*upx + upy*upy <= half2){ hit = true; break; }\n            }\n            if(hit) subCov++;\n          }\n        }\n        fracCovered += subCov \/ 9;\n      }\n    }\n    coveredCount += fracCovered;\n    var cellArea = cellSize * cellSize;\n    var fieldM2 = fieldCellCount * cellArea;\n    var coveredM2 = coveredCount * cellArea;\n    var overlapM2 = overlapCount * cellArea;\n    \/\/ overlapPct = overlap area \/ WORKED area (cells with \u2265 1 hit). This\n    \/\/ matches operator intuition: \"how much of what I worked, did I work\n    \/\/ twice\". Reported as a separate field so the consumer can pick.\n    var overlapOfWorked = coveredCount > 0 ? (overlapCount \/ coveredCount * 100) : 0;\n    return {\n      coveredM2: coveredM2,\n      fieldM2: fieldM2,\n      pct: fieldM2 > 0 ? (coveredM2 \/ fieldM2 * 100) : 0,\n      overlapM2: overlapM2,\n      overlapPct: overlapOfWorked\n    };\n  }\n  \/\/ Count how many times the drive path crosses itself between segments\n  \/\/ belonging to DIFFERENT path elements (pass i vs pass j, ring vs turn\n  \/\/ arc, turn arc vs another turn arc). Within-element self-touches (a\n  \/\/ curved AB pass's adjacent vertices, a turn-arc polyline's own\n  \/\/ sampling) are excluded by tagging each segment with its parent\n  \/\/ element id and skipping same-parent pairs.\n  \/\/\n  \/\/ Rule \u00a76 \u2014 surfaced in analytics + scoring. Body-pass to ring junctions\n  \/\/ at headland endpoints are unavoidable (one per pass end) and treated\n  \/\/ as the baseline; the metric becomes interesting when it exceeds the\n  \/\/ unavoidable junction count (2 \u00d7 nPasses).\n  \/\/\n  \/\/ O(n\u00b2) over segments with bbox-prune. n \u2248 200-800 on typical fields \u2192\n  \/\/ ~40k-640k comparisons. Cheap relative to rasterCoverage.\n  function countDriveCrossings(layout){\n    function segCrossParam(ax, ay, bx, by, cx, cy, dx, dy){\n      var s1x = bx - ax, s1y = by - ay;\n      var s2x = dx - cx, s2y = dy - cy;\n      var denom = (-s2x * s1y + s1x * s2y);\n      if(Math.abs(denom) < 1e-9) return null;\n      var s = (-s1y * (ax - cx) + s1x * (ay - cy)) \/ denom;\n      var t = ( s2x * (ay - cy) - s2y * (ax - cx)) \/ denom;\n      if(s > 0.02 ? s < 0.98 ? t > 0.02 ? t < 0.98 : false : false : false){\n        return { s: s, t: t };\n      }\n      return null;\n    }\n    var segs = [];\n    function pushPolyline(coords, parentId, kind){\n      for(var i=1; i<coords.length; i++){\n        var a = coords[i-1], b = coords[i];\n        var dx = b.x - a.x, dy = b.y - a.y;\n        if(dx * dx + dy * dy < 0.25) continue;\n        segs.push({ x0: a.x, y0: a.y, x1: b.x, y1: b.y, pid: parentId, kind: kind });\n      }\n    }\n    if(layout.passes){\n      for(var pi=0; pi<layout.passes.length; pi++){\n        var p = layout.passes[pi];\n        var pkind = p.kind === 'headland-ring' ? 'ring' : 'body';\n        if(p.samples) pushPolyline(p.samples, 'p' + pi, pkind);\n        else if(p.x0 !== undefined) segs.push({ x0: p.x0, y0: p.y0, x1: p.x1, y1: p.y1, pid: 'p' + pi, kind: pkind });\n      }\n    }\n    if(layout.turnArcs){\n      for(var ti=0; ti<layout.turnArcs.length; ti++){\n        var ta = layout.turnArcs[ti];\n        if(ta.kind === 'transport') continue;\n        if(ta.coords) pushPolyline(ta.coords, 't' + ti, 'arc');\n      }\n    }\n    var n = segs.length;\n    if(n < 4) return 0;\n    var cnt = 0;\n    for(var i=0; i<n; i++){\n      var si = segs[i];\n      var ix0 = si.x0 < si.x1 ? si.x0 : si.x1;\n      var ix1 = si.x0 < si.x1 ? si.x1 : si.x0;\n      var iy0 = si.y0 < si.y1 ? si.y0 : si.y1;\n      var iy1 = si.y0 < si.y1 ? si.y1 : si.y0;\n      for(var j=i+1; j<n; j++){\n        var sj = segs[j];\n        if(sj.pid === si.pid) continue;\n        if(sj.x0 > ix1 ? sj.x1 > ix1 : false) continue;\n        if(sj.x0 < ix0 ? sj.x1 < ix0 : false) continue;\n        if(sj.y0 > iy1 ? sj.y1 > iy1 : false) continue;\n        if(sj.y0 < iy0 ? sj.y1 < iy0 : false) continue;\n        var hit = segCrossParam(si.x0, si.y0, si.x1, si.y1, sj.x0, sj.y0, sj.x1, sj.y1);\n        if(!hit) continue;\n        \/\/ Rule \u00a76 \u2014 arc\/ring overlap is INHERENT to the headland strip:\n        \/\/ the U-turn arc by design swings through the headland zone\n        \/\/ where the ring's wheel-track centerline lives. Counting every\n        \/\/ arc-ring intersection inflates the metric (a single U-turn\n        \/\/ can cross the ring 2\u00d7 \u2014 entering and exiting the strip).\n        \/\/ Exempt ALL arc-vs-ring intersections (operator drives both\n        \/\/ the ring AND the arc during turnarounds \u2014 same painted strip).\n        var arcIsI = si.kind === 'arc';\n        var arcIsJ = sj.kind === 'arc';\n        var ringIsI = si.kind === 'ring';\n        var ringIsJ = sj.kind === 'ring';\n        if((arcIsI ? ringIsJ : false) ? true : (arcIsJ ? ringIsI : false)) continue;\n        \/\/ Body-pass endpoints land ON the ring by construction (pass\n        \/\/ start\/end clipped to inner offset). Exempt body-vs-ring near\n        \/\/ the body pass's endpoints \u2014 the pass tangentially touches the\n        \/\/ ring at start + end, that's not a \"real\" crossing.\n        var bodyIsI = si.kind === 'body';\n        var bodyIsJ = sj.kind === 'body';\n        if((bodyIsI ? ringIsJ : false) ? true : (bodyIsJ ? ringIsI : false)){\n          var bodyParam = bodyIsI ? hit.s : hit.t;\n          if(bodyParam < 0.05 ? true : bodyParam > 0.95) continue;\n        }\n        cnt++;\n      }\n    }\n    return cnt;\n  }\n  function computeMetrics(layout, wM, dieselPrice, consumption){\n    \/\/ Pass (tramline) length only \u2014 what the operator actually works on.\n    var passLen = 0;\n    var nPasses = layout.passes.length;\n    for(var i=0; i<nPasses; i++){\n      var p = layout.passes[i];\n      if(p.samples){\n        for(var s=1; s<p.samples.length; s++){\n          var dx = p.samples[s].x - p.samples[s-1].x;\n          var dy = p.samples[s].y - p.samples[s-1].y;\n          passLen += Math.sqrt(dx*dx + dy*dy);\n        }\n      } else {\n        var dxL = p.x1 - p.x0, dyL = p.y1 - p.y0;\n        passLen += Math.sqrt(dxL*dxL + dyL*dyL);\n      }\n    }\n    \/\/ Turn-arc length \u2014 what the operator drives BETWEEN passes (header up).\n    \/\/ Transport hops between disconnected parts are NOT turnarounds and don't\n    \/\/ burn implement-engaged fuel \u2014 count them in the separate transportLen\n    \/\/ bucket so the turn count + turn-fuel stay honest.\n    var turnLen = 0;\n    var transportLen = 0;\n    var nTurns = 0;\n    if(layout.turnArcs){\n      for(var ti=0; ti<layout.turnArcs.length; ti++){\n        var tArc = layout.turnArcs[ti];\n        var arc = tArc.coords;\n        var arcLen = 0;\n        for(var as=1; as<arc.length; as++){\n          var dxA = arc[as].x - arc[as-1].x, dyA = arc[as].y - arc[as-1].y;\n          arcLen += Math.sqrt(dxA*dxA + dyA*dyA);\n        }\n        \/\/ Implement-up movement (traverse \/ ring-route \/ ring-jump \/\n        \/\/ explicit transport) is NOT a U-turn \u2014 the operator drives the\n        \/\/ headland or worked ring with the implement lifted. Counting\n        \/\/ these in turnLen inflated the metric by 20\u00d7 on L-shape AB\n        \/\/ Straight (reported 2026-06-04 \u2014 \"too big distance, too many\n        \/\/ boundary repeats\"): the 800 m long traverses across the\n        \/\/ concave notch were summed into turn distance, ballooning\n        \/\/ total drive to 36 km vs 13 km for the decomposed approaches.\n        var isLifted = tArc.kind === 'transport'\n          ? true\n          : (tArc.kind === 'traverse'\n              ? true\n              : (tArc.kind === 'ring-route'\n                  ? true\n                  : tArc.kind === 'ring-jump'));\n        if(isLifted){\n          transportLen += arcLen;\n        } else {\n          turnLen += arcLen;\n          nTurns++;\n        }\n      }\n    }\n    \/\/ Total drive path = tramline + turn arcs.\n    \/\/ Pass fuel scales linearly with consumption \u00d7 length, PLUS a slope\n    \/\/ penalty: driving along a contour line is normal-fuel; driving across\n    \/\/ contours (uphill\/downhill) penalises ~10\u00d7 the absolute grade\u00b2. Real\n    \/\/ tractors burn 30\u201340 % more diesel at a sustained 5 % up-grade.\n    \/\/ Turnaround fuel is 1.25\u00d7 per metre because the engine works harder\n    \/\/ under a tight pivot (hydraulics raising\/lowering the implement,\n    \/\/ gear changes) \u2014 typical headland-turn penalty.\n    var totalLen = passLen + turnLen + transportLen;\n    var slopePenalty = computeSlopePenalty(layout);\n    var passFuelBase = (passLen \/ 1000) * consumption;\n    var passFuel = passFuelBase * (1 + slopePenalty);\n    var turnFuel = (turnLen \/ 1000) * consumption * 1.25;\n    \/\/ Transport hops drive the road (no implement load) \u2014 flat fuel burn.\n    var transportFuel = (transportLen \/ 1000) * consumption;\n    var totalFuel = passFuel + turnFuel + transportFuel;\n    var cost = totalFuel * dieselPrice;\n    \/\/ Coverage \u2014 RASTER-BASED for accuracy. Sum-of-strip math overstated\n    \/\/ (double-counted overlaps, ignored corner gaps and headland-ring under-fill\n    \/\/ at sharp corners) \u2192 clamped to 100% even when the real coverage was <95%.\n    \/\/ The grid approach computes the true union area: for each cell whose\n    \/\/ centre lies inside the field boundary, check if it's also inside any\n    \/\/ swath strip. coveragePct = covered \/ total. cellSize defaults to wM\/3\n    \/\/ so cells are smaller than the swath, capturing under-coverage near\n    \/\/ corners and uncovered pockets in concave fields.\n    \/\/ Multi-part: sum part areas; single-part: just the polygon.\n    var statsParts = layout.boundaryParts ? (layout.boundaryParts.length ? layout.boundaryParts : [layout.boundary ? layout.boundary : BOUNDARY]) : [layout.boundary ? layout.boundary : BOUNDARY];\n    var fieldM2 = 0;\n    for(var sp=0; sp<statsParts.length; sp++){\n      fieldM2 += fieldStats(statsParts[sp]).area;\n      \/\/ Subtract obstacle\/hole area so field area + missed-area math\n      \/\/ reflect the actually-workable ground, not the gross polygon.\n      if(statsParts[sp].holes ? statsParts[sp].holes.length > 0 : false){\n        for(var fh=0; fh<statsParts[sp].holes.length; fh++){\n          fieldM2 -= polyArea(statsParts[sp].holes[fh]);\n        }\n      }\n    }\n    if(fieldM2 < 0) fieldM2 = 0;\n    var coverageRes = rasterCoverage(layout, wM);\n    var coveredM2 = coverageRes.coveredM2;\n    var coveragePct = coverageRes.pct;\n    var overlapPct = coverageRes.overlapPct || 0;\n    var overlapM2 = coverageRes.overlapM2 || 0;\n    \/\/ Path-crossings count \u2014 how many times the drive path crosses itself,\n    \/\/ measured between segments that belong to different passes \/ turn arcs.\n    \/\/ A high count signals the planner is sending the machine over ground\n    \/\/ it already drove (wheel-track repeats) AND visually marks a sub-\n    \/\/ optimal plan. Rule \u00a76 \u2014 used in scoring + surfaced in analytics.\n    var crossings = countDriveCrossings(layout);\n    return {\n      passes: nPasses,\n      passLengthM: passLen,\n      turnLengthM: turnLen,\n      totalDriveM: totalLen,\n      turns: nTurns,\n      fuelL: totalFuel,\n      passFuelL: passFuel,\n      turnFuelL: turnFuel,\n      costUSD: cost,\n      coveragePct: coveragePct,\n      coveredM2: coveredM2,\n      fieldM2: fieldM2,\n      overlapM2: overlapM2,\n      overlapPct: overlapPct,\n      crossings: crossings,\n      slopePenalty: slopePenalty,\n      avgGradePct: layout.avgGradePct || 0,\n      \/\/ Auto-blocks sub-block count (>1 when the planner decomposed a\n      \/\/ single concave polygon into multiple convex sub-blocks). Used\n      \/\/ by recommendByMetrics to fire the multi-part bonus for fields\n      \/\/ like the redesigned L-shape where the decomposition is the\n      \/\/ whole reason to pick auto-blocks.\n      blockCount: layout.blocks ? layout.blocks.length : 1\n    };\n  }\n  \/\/ Compute average grade-along-pass-direction for body passes, returns\n  \/\/ a fuel multiplier penalty (0 = flat, 0.3 = 30% extra fuel). Also stores\n  \/\/ the average absolute grade % on the layout for display. Penalty model:\n  \/\/   penalty = k_lin \u00d7 mean(|grade|) + k_sq \u00d7 mean(|grade|\u00b2)\n  \/\/ The combined model captures BOTH the constant headwind of climbing\n  \/\/ (linear in grade \u2014 gravity force \u00d7 cos for the implement + roll\n  \/\/ resistance bump on soft soil) AND the quadratic punishment when grade\n  \/\/ gets steep (engine inefficiency at low gear, hydraulic load spikes).\n  \/\/ Calibration against published tractor-power-curve trial data:\n  \/\/   5% sustained grade \u2192 ~25 % extra fuel\n  \/\/   10% sustained grade \u2192 ~55 % extra fuel\n  \/\/   15% sustained grade \u2192 ~90 % extra fuel\n  \/\/ Cross-contour passes accumulate the full grade; on-contour passes\n  \/\/ (Contour-follow \/ Adaptive) accumulate ~0. This is what makes contour\n  \/\/ planning visibly cheaper on hilly fields \u2014 the AB-straight plan's\n  \/\/ pass-fuel can swing 40\u201380 % higher than Contour-follow's on a 10 %\n  \/\/ ridge, which dwarfs the extra km that AB saves.\n  function computeSlopePenalty(layout){\n    if(!layout.passes ? true : layout.passes.length === 0) return 0;\n    var totalLen = 0;\n    var weightedGrade2 = 0;\n    var weightedAbsGrade = 0;\n    var sampleStep = 4;  \/\/ metres\n    function accumulateSeg(x0, y0, x1, y1){\n      var sdx = x1 - x0, sdy = y1 - y0;\n      var L = Math.sqrt(sdx*sdx + sdy*sdy);\n      if(L < 0.5) return;\n      var nS = Math.max(2, Math.ceil(L \/ sampleStep));\n      var z0 = terrainAt(x0, y0);\n      for(var k=1; k<=nS; k++){\n        var t = k \/ nS;\n        var mx = x0 + sdx * t, my = y0 + sdy * t;\n        var z1 = terrainAt(mx, my);\n        var dh = L \/ nS;\n        var grade = dh > 1e-6 ? (z1 - z0) \/ dh : 0;\n        weightedGrade2 += grade * grade * dh;\n        weightedAbsGrade += Math.abs(grade) * dh;\n        totalLen += dh;\n        z0 = z1;\n      }\n    }\n    for(var pi=0; pi<layout.passes.length; pi++){\n      var pa = layout.passes[pi];\n      if(pa.kind === 'headland-ring') continue;  \/\/ ring follows boundary, ignore slope cost\n      if(pa.samples){\n        for(var si=1; si<pa.samples.length; si++){\n          accumulateSeg(pa.samples[si-1].x, pa.samples[si-1].y, pa.samples[si].x, pa.samples[si].y);\n        }\n      } else if(pa.x0 !== undefined){\n        accumulateSeg(pa.x0, pa.y0, pa.x1, pa.y1);\n      }\n    }\n    if(totalLen < 1) return 0;\n    var meanGrade2 = weightedGrade2 \/ totalLen;\n    var meanAbsGrade = weightedAbsGrade \/ totalLen;\n    layout.avgGradePct = meanAbsGrade * 100;  \/\/ for display\n    \/\/ Combined linear + quadratic model \u2014 calibrated AGGRESSIVELY so the\n    \/\/ Contour-follow value-prop is unambiguous on the demo terrains. Real\n    \/\/ tractor field data shows pass-fuel rises sharply with sustained\n    \/\/ grade in low gear (gravity + hydraulic load + drivetrain\n    \/\/ inefficiency at higher torque). Targets:\n    \/\/  @  3% grade \u2192  ~25% penalty\n    \/\/  @  5% grade \u2192  ~45% penalty\n    \/\/  @  8% grade \u2192  ~75% penalty\n    \/\/  @ 10% grade \u2192  ~100% penalty\n    \/\/  @ 15% grade \u2192 cap (160% penalty)\n    var penalty = 7 * meanAbsGrade + 30 * meanGrade2;\n    if(penalty > 1.6) penalty = 1.6;\n    return penalty;\n  }\n  \/\/ RENDER\n  var DPR = window.devicePixelRatio > 1 ? window.devicePixelRatio : 1;\n  function resize(){\n    var rect = canvas.getBoundingClientRect();\n    canvas.width = Math.floor(rect.width * DPR);\n    canvas.height = Math.floor(rect.height * DPR);\n    ctx.setTransform(DPR, 0, 0, DPR, 0, 0);\n  }\n  \/\/ View transform \u2014 fit-to-field is the base, then user can zoom\/pan around.\n  var view = { zoom: 1, panX: 0, panY: 0 };\n  function getScale(){\n    var rect = canvas.getBoundingClientRect();\n    var pad = 36;\n    \/\/ Bounding box covers ALL parts of a multi-polygon field so the\n    \/\/ initial fit shows every block + the inter-block gap. Previously\n    \/\/ only the primary (largest) polygon was framed \u2192 on the 2-block\n    \/\/ sample (and any multi-part import) one block sat outside the\n    \/\/ visible canvas until the user manually panned (reported\n    \/\/ 2026-06-03).\n    var partsForFit = BOUNDARY_PARTS ? (BOUNDARY_PARTS.length > 0 ? BOUNDARY_PARTS : [BOUNDARY]) : [BOUNDARY];\n    var s = fieldStatsAll(partsForFit);\n    var sx = (rect.width - pad * 2) \/ (s.maxX - s.minX);\n    var sy = (rect.height - pad * 2) \/ (s.maxY - s.minY);\n    var sc = (sx < sy ? sx : sy) * view.zoom;\n    var baseTx = pad - s.minX * sc + (rect.width - pad * 2 - (s.maxX - s.minX) * sc) \/ 2;\n    var baseTy = pad - s.minY * sc + (rect.height - pad * 2 - (s.maxY - s.minY) * sc) \/ 2;\n    return { sc: sc, tx: baseTx + view.panX, ty: baseTy + view.panY };\n  }\n  function px(proj, x){ return x * proj.sc + proj.tx; }\n  function py(proj, y){ return y * proj.sc + proj.ty; }\n  \/\/ Inverse: screen \u2192 world coordinates (used by ruler + pan).\n  function worldX(proj, sx){ return (sx - proj.tx) \/ proj.sc; }\n  function worldY(proj, sy){ return (sy - proj.ty) \/ proj.sc; }\n  \/\/ Unit + ruler state\n  \/\/ Unit system \u2014 single source of truth for ALL number rendering + input\n  \/\/ parsing. Internal math runs in metric (m, km, ha, L). The display layer\n  \/\/ converts to US (ft, mi, ac, gal) when unitSystem === 'us'. getInputs()\n  \/\/ converts user-entered values BACK to metric so the geometry pipeline\n  \/\/ always sees metric. Switching the toggle ALSO converts input field\n  \/\/ values so the user sees consistent units everywhere (e.g. typing 18 m\n  \/\/ then switching to US shows 59 ft, not 18 ft).\n  var unitSystem = 'metric';\n  var M_TO_FT   = 3.28084;\n  var KM_TO_MI  = 0.621371;\n  var HA_TO_AC  = 2.47105;\n  var L_TO_GAL  = 0.264172;\n  var LPKM_TO_GPMI = L_TO_GAL \/ KM_TO_MI;  \/\/ 1 L\/km in gal\/mi (~0.425)\n  var LPL_TO_DPGAL = 1 \/ L_TO_GAL;          \/\/ 1 $\/L in $\/gal (~3.785)\n  function fmtArea(ha){\n    if(unitSystem === 'us') return (ha * HA_TO_AC).toFixed(1) + ' ac';\n    return ha.toFixed(1) + ' ha';\n  }\n  function fmtVolume(L){\n    if(unitSystem === 'us') return (L * L_TO_GAL).toFixed(1) + ' gal';\n    return L.toFixed(1) + ' L';\n  }\n  function fmtWidth(m){\n    if(unitSystem === 'us') return (m * M_TO_FT).toFixed(0) + ' ft';\n    return m.toFixed(0) + ' m';\n  }\n  \/\/ Legacy variable kept so the playback stats string (which still embeds\n  \/\/ \"km\" literal in places) doesn't break. The active km vs mi vs ft choice\n  \/\/ is driven by fmtDist below.\n  var unit = 'km';\n  var ruler = { active: false, p1: null, p2: null };\n  \/\/ AB-line tool \u2014 click two points on the canvas to lock the guidance heading\n  \/\/ (a \"vector of main path\"). On second click the angle replaces userAxisDeg\n  \/\/ and a persistent orange dashed vector + arrowhead is rendered until the\n  \/\/ user hits the Clear toolbar button or picks a different sample field.\n  var abTool = { active: false, p1: null, p2: null };\n  function fmtDist(metres){\n    if(unitSystem === 'us'){\n      var mi = metres \/ 1609.344;\n      if(mi >= 0.2) return mi.toFixed(2) + ' mi';\n      var ft = metres * M_TO_FT;\n      return ft.toFixed(0) + ' ft';\n    }\n    if(metres >= 1000) return (metres \/ 1000).toFixed(2) + ' km';\n    return metres.toFixed(0) + ' m';\n  }\n  \/\/ PLAYBACK STATE \u2014 animates a tractor sprite along the drive path with a\n  \/\/ gradually-filling swath band behind it. Speed is \"wall-clock \u00d7 multiplier\";\n  \/\/ we map ~1 km of drive-path per second of wall clock at 1\u00d7 so a typical\n  \/\/ field plays out in 10\u201320 s.\n  var playback = {\n    isPlaying: false,\n    t: 0,              \/\/ 0..1 progress along total drive-path length\n    speed: 1,          \/\/ 1 \/ 2 \/ 4 multiplier\n    drivePath: [],\n    cumLen: [0],       \/\/ cumulative length to each vertex\n    totalLen: 0,       \/\/ metres\n    wM: 18,            \/\/ implement width for swath\n    lastTick: 0\n  };\n  \/\/ Build (or rebuild) the playback path from a layout. Resets progress.\n  \/\/ Subdivides long segments (line-based passes can be 600 m end-to-end) so\n  \/\/ the sprite advances smoothly instead of skipping in big chunks (which\n  \/\/ looked like \"teleports\" at 4\u00d7 speed).\n  function setPlaybackPath(layout, wM){\n    var raw = layout.drivePath ? layout.drivePath : [];\n    var maxStep = Math.max(2, wM);  \/\/ never exceed one implement-width per drive-path segment\n    var dense = [];\n    for(var k=0; k<raw.length; k++){\n      if(k === 0){ dense.push(raw[0]); continue; }\n      var prev = raw[k - 1], cur = raw[k];\n      var ddx = cur.x - prev.x, ddy = cur.y - prev.y;\n      var dlen = Math.sqrt(ddx*ddx + ddy*ddy);\n      if(dlen > maxStep){\n        var nSub = Math.ceil(dlen \/ maxStep);\n        for(var ns=1; ns<nSub; ns++){\n          var f = ns \/ nSub;\n          dense.push({ x: prev.x + ddx * f, y: prev.y + ddy * f });\n        }\n      }\n      dense.push(cur);\n    }\n    playback.drivePath = dense;\n    playback.wM = wM;\n    playback.cumLen = [0];\n    var total = 0;\n    for(var i=1; i<playback.drivePath.length; i++){\n      var dx = playback.drivePath[i].x - playback.drivePath[i-1].x;\n      var dy = playback.drivePath[i].y - playback.drivePath[i-1].y;\n      total += Math.sqrt(dx*dx + dy*dy);\n      playback.cumLen.push(total);\n    }\n    playback.totalLen = total;\n    playback.t = 0;\n    updatePlaybackUI();\n  }\n  \/\/ Return the (x, y, headingRad) at parametric position t (0..1) along the path.\n  function playbackPosAt(t){\n    if(playback.drivePath.length < 2) return null;\n    var target = t * playback.totalLen;\n    var lo = 0, hi = playback.cumLen.length - 1;\n    while(lo < hi){\n      var mid = (lo + hi + 1) >> 1;\n      if(playback.cumLen[mid] <= target) lo = mid; else hi = mid - 1;\n    }\n    if(lo >= playback.drivePath.length - 1) lo = playback.drivePath.length - 2;\n    var segLen = playback.cumLen[lo + 1] - playback.cumLen[lo];\n    var f = segLen > 1e-6 ? (target - playback.cumLen[lo]) \/ segLen : 0;\n    var p0 = playback.drivePath[lo];\n    var p1 = playback.drivePath[lo + 1];\n    return {\n      x: p0.x + (p1.x - p0.x) * f,\n      y: p0.y + (p1.y - p0.y) * f,\n      heading: Math.atan2(p1.y - p0.y, p1.x - p0.x),\n      segIdx: lo\n    };\n  }\n  function updatePlaybackUI(){\n    var fillEl = document.getElementById('gpl-pb-fill');\n    var thumbEl = document.getElementById('gpl-pb-thumb');\n    var statsEl = document.getElementById('gpl-pb-stats');\n    var pct = (playback.t * 100).toFixed(0);\n    if(fillEl) fillEl.style.width = pct + '%';\n    if(thumbEl) thumbEl.style.left = pct + '%';\n    if(statsEl){\n      statsEl.textContent = pct + '% \u00b7 ' + fmtDist(playback.t * playback.totalLen) + ' \/ ' + fmtDist(playback.totalLen);\n    }\n  }\n  function drawSwath(proj){\n    if(playback.drivePath.length < 2 ? true : playback.t <= 0) return;\n    var target = playback.t * playback.totalLen;\n    var halfW = playback.wM * 0.5 * proj.sc;\n    if(halfW < 1) halfW = 1;\n    ctx.save();\n    ctx.strokeStyle = 'rgba(247,106,12,0.32)';\n    ctx.lineWidth = halfW * 2;\n    \/\/ Butt caps (not round) \u2014 round caps extend a semicircle of radius wM\/2\n    \/\/ BEYOND each pass endpoint. Body passes end exactly on the inner-headland\n    \/\/ edge, so a round cap re-paints the headland strip with another wM\/2 of\n    \/\/ swath at every pass end. Rule \u00a76 \u2014 swath paints once per strip, never\n    \/\/ doubled. Butt caps cut the swath flat at endpoints so the body pass +\n    \/\/ headland ring swaths abut cleanly with zero overlap. lineJoin stays\n    \/\/ round for smooth interior joints on curved (AB-curve \/ contour) passes.\n    ctx.lineCap = 'butt';\n    ctx.lineJoin = 'round';\n    \/\/ Transport segments (transport:true on a point) get NO swath \u2014 the\n    \/\/ implement is lifted while the operator drives between disconnected\n    \/\/ parts. We \"break\" the stroke at each transport hop using moveTo.\n    var penDown = !playback.drivePath[0].transport;\n    ctx.beginPath();\n    if(penDown) ctx.moveTo(px(proj, playback.drivePath[0].x), py(proj, playback.drivePath[0].y));\n    for(var i=1; i<playback.drivePath.length; i++){\n      var prev = playback.drivePath[i-1];\n      var here = playback.drivePath[i];\n      var isTransport = here.transport ? true : prev.transport;\n      var capped = playback.cumLen[i] > target;\n      var ex, ey;\n      if(capped){\n        var segLen = playback.cumLen[i] - playback.cumLen[i-1];\n        var f = segLen > 1e-6 ? (target - playback.cumLen[i-1]) \/ segLen : 0;\n        ex = prev.x + (here.x - prev.x) * f;\n        ey = prev.y + (here.y - prev.y) * f;\n      } else {\n        ex = here.x; ey = here.y;\n      }\n      if(isTransport){\n        \/\/ Skip swath for this segment \u2014 lift the pen and move on\n        if(penDown) ctx.stroke();\n        ctx.beginPath();\n        penDown = false;\n      } else {\n        if(!penDown){\n          ctx.moveTo(px(proj, prev.x), py(proj, prev.y));\n          penDown = true;\n        }\n        ctx.lineTo(px(proj, ex), py(proj, ey));\n      }\n      if(capped) break;\n    }\n    if(penDown) ctx.stroke();\n    ctx.restore();\n  }\n  \/\/ GeoPard-brand elevation rainbow: 5 keypoints spanning t \u2208 [0, 1]\n  \/\/   0.00 \u2192 deep purple (low)\n  \/\/   0.25 \u2192 teal \/ cyan\n  \/\/   0.50 \u2192 soft yellow\n  \/\/   0.75 \u2192 orange\n  \/\/   1.00 \u2192 red (high)\n  \/\/ Linearly interpolated between adjacent stops in RGB space. Matches the\n  \/\/ colour scheme used in the GeoPard Platform's elevation legend.\n  var ELEV_STOPS = [\n    [109,  79, 162],   \/\/ 0.00 \u2014 purple\n    [ 78, 192, 167],   \/\/ 0.25 \u2014 teal\n    [255, 232, 130],   \/\/ 0.50 \u2014 yellow\n    [245, 168,  92],   \/\/ 0.75 \u2014 orange\n    [219,  80,  80]    \/\/ 1.00 \u2014 red\n  ];\n  function elevColor(t){\n    if(t <= 0) return ELEV_STOPS[0];\n    if(t >= 1) return ELEV_STOPS[ELEV_STOPS.length - 1];\n    var seg = t * (ELEV_STOPS.length - 1);\n    var i = Math.floor(seg);\n    var f = seg - i;\n    var a = ELEV_STOPS[i], b = ELEV_STOPS[i + 1];\n    return [\n      Math.round(a[0] + (b[0] - a[0]) * f),\n      Math.round(a[1] + (b[1] - a[1]) * f),\n      Math.round(a[2] + (b[2] - a[2]) * f)\n    ];\n  }\n  \/\/ Terrain heatmap \u2014 translucent rainbow gradient showing elevation.\n  \/\/ Higher cell density (48\u00d736) + GeoPard brand colours @ 0.6 alpha so the\n  \/\/ elevation pattern reads clearly while passes \/ arcs \/ sprite stay legible.\n  function drawTerrain(proj){\n    if(currentField === 'custom') return;  \/\/ no synthetic terrain for uploaded\n    \/\/ Grid bbox covers ALL parts of a multi-polygon field. Previously\n    \/\/ only the primary polygon was used \u2192 on the 2-block sample the\n    \/\/ second polygon had no elevation heatmap behind it.\n    var terrainParts = BOUNDARY_PARTS ? (BOUNDARY_PARTS.length > 0 ? BOUNDARY_PARTS : [BOUNDARY]) : [BOUNDARY];\n    var stats = fieldStatsAll(terrainParts);\n    function cellInAnyPart(x, y){\n      for(var pi=0; pi<terrainParts.length; pi++){\n        if(pointInPoly(x, y, terrainParts[pi])) return true;\n      }\n      return false;\n    }\n    var nx = 48, ny = 36;\n    var dx = (stats.maxX - stats.minX) \/ nx;\n    var dy = (stats.maxY - stats.minY) \/ ny;\n    \/\/ Find elevation range across cells inside ANY part of the field.\n    var lo = Infinity, hi = -Infinity;\n    var grid = new Float32Array(nx * ny);\n    for(var iy=0; iy<ny; iy++){\n      for(var ix=0; ix<nx; ix++){\n        var cx = stats.minX + (ix + 0.5) * dx;\n        var cy = stats.minY + (iy + 0.5) * dy;\n        var z = terrainAt(cx, cy);\n        grid[iy * nx + ix] = z;\n        if(cellInAnyPart(cx, cy)){\n          if(z < lo) lo = z;\n          if(z > hi) hi = z;\n        }\n      }\n    }\n    if(hi - lo < 0.5) return;\n    ctx.save();\n    \/\/ Clip to the union of all boundary parts so cells don't leak into\n    \/\/ the gap between disconnected polygons.\n    ctx.clip(buildBoundaryPath(proj, terrainParts), 'evenodd');\n    for(var iy2=0; iy2<ny; iy2++){\n      for(var ix2=0; ix2<nx; ix2++){\n        var cx2 = stats.minX + (ix2 + 0.5) * dx;\n        var cy2 = stats.minY + (iy2 + 0.5) * dy;\n        if(!cellInAnyPart(cx2, cy2)) continue;\n        var z2 = grid[iy2 * nx + ix2];\n        var t = (z2 - lo) \/ (hi - lo);\n        var c = elevColor(t);\n        ctx.fillStyle = 'rgba(' + c[0] + ',' + c[1] + ',' + c[2] + ',0.6)';\n        var x0 = px(proj, stats.minX + ix2 * dx);\n        var y0 = py(proj, stats.minY + iy2 * dy);\n        var x1 = px(proj, stats.minX + (ix2 + 1) * dx);\n        var y1 = py(proj, stats.minY + (iy2 + 1) * dy);\n        ctx.fillRect(x0 - 0.5, y0 - 0.5, (x1 - x0) + 1, (y1 - y0) + 1);\n      }\n    }\n    ctx.restore();\n  }\n  \/\/ Soil compaction zones \u2014 wherever the machine turns around, it's compacting\n  \/\/ the headland strip soil with extra passes. Visualise as translucent violet\n  \/\/ strokes under each turn-arc swath; multiple nearby arcs additively darken\n  \/\/ the same area, making heavy-compaction clusters visible.\n  function drawCompactionZones(proj, layout){\n    var hasPasses = layout.passes ? layout.passes.length > 0 : false;\n    var hasArcs = layout.turnArcs ? layout.turnArcs.length > 0 : false;\n    if(!hasPasses ? !hasArcs : false) return;\n    \/\/ Wheel-track compaction. Painted ONLY for paths that OVERLAP other\n    \/\/ paths \u2014 concretely: headland rings (which every U-turn arc crosses)\n    \/\/ + turn arcs (which all sit inside the headland strip and overlap\n    \/\/ each other at the boundary). Body passes intentionally skipped \u2014 they\n    \/\/ run parallel at wM spacing so their wheel tracks never overlap with\n    \/\/ each other; painting them just adds visual noise (per CLAUDE.md\n    \/\/ rule \u00a76 \"minimise path crossings\"). The result: violet wash is dense\n    \/\/ where wheels actually compact the soil repeatedly (headland strip,\n    \/\/ turn zones, corner clusters) and absent where each pass only laid\n    \/\/ down its single track (body interior).\n    \/\/\n    \/\/ Stroke width is wM \u00d7 0.18 (~realistic wheel-track scale for a 4-wheel\n    \/\/ tractor \u2014 two axles \u2248 18% of the implement width when combined).\n    var wheelW = playback.wM * 0.18 * proj.sc;\n    if(wheelW < 1) wheelW = 1;\n    ctx.save();\n    ctx.lineWidth = wheelW * 2;\n    ctx.lineCap = 'round';\n    ctx.lineJoin = 'round';\n    \/\/ 1) Headland rings (kind === 'headland-ring'). Every U-turn arc sits\n    \/\/    on the same headland strip the ring drove, so the violet wash from\n    \/\/    ring + arcs stacks visibly at the boundary.\n    ctx.strokeStyle = 'rgba(162,28,175,0.10)';\n    if(hasPasses){\n      for(var pi=0; pi<layout.passes.length; pi++){\n        var pa = layout.passes[pi];\n        if(pa.kind !== 'headland-ring') continue;\n        if(pa.samples ? pa.samples.length >= 2 : false){\n          ctx.beginPath();\n          ctx.moveTo(px(proj, pa.samples[0].x), py(proj, pa.samples[0].y));\n          for(var s=1; s<pa.samples.length; s++){\n            ctx.lineTo(px(proj, pa.samples[s].x), py(proj, pa.samples[s].y));\n          }\n          ctx.stroke();\n        }\n      }\n    }\n    \/\/ 2) U-turn arcs (and racetracks). Slightly higher alpha because turns\n    \/\/    concentrate wheel slip + repeated motion in a tight zone, so the\n    \/\/    compaction effect is genuinely worse per pass than a straight body\n    \/\/    pass. Transport hops are skipped (those happen on roads\/tracks\n    \/\/    between disconnected field parts, not on the field itself).\n    ctx.strokeStyle = 'rgba(162,28,175,0.12)';\n    if(hasArcs){\n      for(var ti=0; ti<layout.turnArcs.length; ti++){\n        var arc = layout.turnArcs[ti];\n        if(arc.kind === 'transport') continue;\n        if(arc.coords.length < 2) continue;\n        ctx.beginPath();\n        ctx.moveTo(px(proj, arc.coords[0].x), py(proj, arc.coords[0].y));\n        for(var ac=1; ac<arc.coords.length; ac++){\n          ctx.lineTo(px(proj, arc.coords[ac].x), py(proj, arc.coords[ac].y));\n        }\n        ctx.stroke();\n      }\n    }\n    ctx.restore();\n  }\n  \/\/ Direction arrows \u2014 small chevron at the middle of each pass + arc, pointing\n  \/\/ along the drive direction. Makes the snake\/serpentine flow visible.\n  function drawDirectionArrows(proj, layout){\n    var arrowPx = 8;\n    function drawArrow(midX, midY, headingRad, color){\n      ctx.save();\n      ctx.translate(midX, midY);\n      ctx.rotate(headingRad);\n      ctx.fillStyle = color;\n      ctx.beginPath();\n      ctx.moveTo(arrowPx, 0);\n      ctx.lineTo(-arrowPx * 0.6, -arrowPx * 0.6);\n      ctx.lineTo(-arrowPx * 0.6, arrowPx * 0.6);\n      ctx.closePath();\n      ctx.fill();\n      ctx.restore();\n    }\n    \/\/ Pass arrows \u2014 orange to match the pass strokes. Use orderedPasses\n    \/\/ (post-snake, drive-direction oriented) so chevrons match actual flow:\n    \/\/ odd passes get reversed arrows, mirroring the operator's reverse drive.\n    \/\/\n    \/\/ Ring passes (kind === 'headland-ring') get arrows ONLY when the\n    \/\/ layout is rings-only (Boundary Follow). For approaches that mix\n    \/\/ headland ring + parallel body passes (AB Straight \/ AB Curve \/\n    \/\/ Adaptive), the single perimeter ring's direction is already shown\n    \/\/ by the U-turn chevrons at body-pass endpoints, so skipping it\n    \/\/ keeps the visual clean. Boundary Follow's spiral consists ENTIRELY\n    \/\/ of headland-ring kinds; without this branch the operator sees no\n    \/\/ direction arrows at all (reported as \"arrows not always shown\").\n    var passList = layout.orderedPasses ? layout.orderedPasses : layout.passes;\n    var hasNonRingPass = false;\n    if(passList){\n      for(var pn=0; pn<passList.length; pn++){\n        if(passList[pn].kind !== 'headland-ring'){ hasNonRingPass = true; break; }\n      }\n    }\n    if(passList){\n      for(var p=0; p<passList.length; p++){\n        var pa = passList[p];\n        if(pa.kind === 'headland-ring' ? hasNonRingPass : false) continue;\n        var ax, ay, bx, by;\n        if(pa.samples ? pa.samples.length >= 2 : false){\n          var midI = Math.floor(pa.samples.length \/ 2);\n          ax = pa.samples[midI - 1].x; ay = pa.samples[midI - 1].y;\n          bx = pa.samples[midI].x;     by = pa.samples[midI].y;\n        } else if(pa.x0 !== undefined){\n          ax = pa.x0; ay = pa.y0; bx = pa.x1; by = pa.y1;\n        } else continue;\n        var midX = px(proj, (ax + bx) * 0.5);\n        var midY = py(proj, (ay + by) * 0.5);\n        drawArrow(midX, midY, Math.atan2(by - ay, bx - ax), '#f76a0c');\n      }\n    }\n    \/\/ Turnaround arrows \u2014 violet, halfway through the arc. Transport hops\n    \/\/ get a gray arrow so the user sees direction across the gap.\n    if(layout.turnArcs){\n      for(var ti=0; ti<layout.turnArcs.length; ti++){\n        var arcObj = layout.turnArcs[ti];\n        var arc = arcObj.coords;\n        if(arc.length < 3 ? arc.length < 2 : false) continue;\n        var mi = Math.max(1, Math.floor(arc.length \/ 2));\n        var aX = arc[mi - 1].x, aY = arc[mi - 1].y;\n        var bX = arc[mi].x,     bY = arc[mi].y;\n        var amX = px(proj, (aX + bX) * 0.5);\n        var amY = py(proj, (aY + bY) * 0.5);\n        var arrowColor = arcObj.kind === 'transport' ? '#4c6066' : '#a21caf';\n        drawArrow(amX, amY, Math.atan2(bY - aY, bX - aX), arrowColor);\n      }\n    }\n  }\n  function drawSprite(proj){\n    var pos = playbackPosAt(playback.t);\n    if(!pos) return;\n    var halfW = playback.wM * 0.5 * proj.sc;\n    if(halfW < 6) halfW = 6;\n    var bodyLen = Math.max(10, halfW * 0.6);\n    ctx.save();\n    ctx.translate(px(proj, pos.x), py(proj, pos.y));\n    ctx.rotate(pos.heading);\n    \/\/ Implement bar (the wide piece behind the cab \u2014 orange to match passes)\n    ctx.fillStyle = '#f76a0c';\n    ctx.fillRect(-bodyLen * 0.4, -halfW, bodyLen * 0.35, halfW * 2);\n    \/\/ Tractor cab (compact dark-green block in front of the implement)\n    ctx.fillStyle = '#145328';\n    ctx.fillRect(0, -bodyLen * 0.35, bodyLen * 0.55, bodyLen * 0.7);\n    \/\/ Cab roof\n    ctx.fillStyle = '#1a7951';\n    ctx.fillRect(bodyLen * 0.1, -bodyLen * 0.22, bodyLen * 0.3, bodyLen * 0.44);\n    \/\/ GeoPard \"G\" badge on the cab roof \u2014 counter-rotated so it stays\n    \/\/ upright regardless of tractor heading. Skips when the sprite is\n    \/\/ tiny (zoomed out) \u2014 the letter would be sub-pixel anyway.\n    var roofCx = bodyLen * 0.25;  \/\/ centre of roof in local coords\n    var roofCy = 0;\n    var badgeFontPx = Math.max(6, bodyLen * 0.32);\n    if(badgeFontPx >= 7){\n      ctx.save();\n      ctx.translate(roofCx, roofCy);\n      ctx.rotate(-pos.heading);  \/\/ undo tractor rotation so G reads upright\n      ctx.font = '700 ' + badgeFontPx.toFixed(0) + 'px \"Poppins\", system-ui, sans-serif';\n      ctx.textAlign = 'center';\n      ctx.textBaseline = 'middle';\n      ctx.fillStyle = '#fafbf4';\n      ctx.fillText('G', 0, 0.5);\n      ctx.restore();\n    }\n    ctx.restore();\n  }\n  \/\/ Helper \u2014 trace ALL boundary parts as a single Path2D so we can use it as\n  \/\/ a clip region. The renderer uses this to ensure swath\/passes\/arcs\/turn\n  \/\/ strokes never paint outside the field boundary, even when the underlying\n  \/\/ geometry would extend a fat-line stroke past a concave or curved edge.\n  function buildBoundaryPath(proj, parts){\n    var path = new Path2D();\n    for(var pi=0; pi<parts.length; pi++){\n      var part = parts[pi];\n      if(!part ? true : part.length < 3) continue;\n      path.moveTo(px(proj, part[0].x), py(proj, part[0].y));\n      for(var v=1; v<part.length; v++){\n        path.lineTo(px(proj, part[v].x), py(proj, part[v].y));\n      }\n      path.closePath();\n      \/\/ Holes \u2014 add each as its own subpath. With evenodd fill\/clip the\n      \/\/ hole interior becomes 2-layers = OUTSIDE the region, so swath +\n      \/\/ passes + terrain are masked out of obstacles automatically.\n      if(part.holes ? part.holes.length > 0 : false){\n        for(var hh=0; hh<part.holes.length; hh++){\n          var hole = part.holes[hh];\n          if(!hole ? true : hole.length < 3) continue;\n          path.moveTo(px(proj, hole[0].x), py(proj, hole[0].y));\n          for(var hv=1; hv<hole.length; hv++){\n            path.lineTo(px(proj, hole[hv].x), py(proj, hole[hv].y));\n          }\n          path.closePath();\n        }\n      }\n    }\n    return path;\n  }\n  function draw(layout){\n    var rect = canvas.getBoundingClientRect();\n    \/\/ Re-sync the backing buffer to the canvas's CURRENT CSS size before\n    \/\/ clearing. Reported 2026-06-03: switching fields after the canvas's\n    \/\/ CSS dimensions had changed (sidebar drag, viewport resize, parent\n    \/\/ grid reflow) left a triangular blob of the OLD render at the\n    \/\/ bottom of the canvas. Cause: when CSS dims shrank but\n    \/\/ canvas.width\/height attributes still held the old (larger) backing\n    \/\/ size, clearRect only zeroed the new CSS area while the browser\n    \/\/ continued to scale the larger backing buffer (including the\n    \/\/ un-cleared bottom strip) to fit the smaller display rectangle.\n    \/\/ Re-asserting buffer size on every draw is cheap (a property check)\n    \/\/ and a no-op when dimensions already match.\n    var expectedW = Math.floor(rect.width * DPR);\n    var expectedH = Math.floor(rect.height * DPR);\n    if(canvas.width !== expectedW ? true : canvas.height !== expectedH){\n      canvas.width = expectedW;\n      canvas.height = expectedH;\n      ctx.setTransform(DPR, 0, 0, DPR, 0, 0);\n    }\n    ctx.clearRect(0, 0, rect.width, rect.height);\n    var proj = getScale();\n    var boundary = layout.boundary ? layout.boundary : BOUNDARY;\n    var parts = layout.boundaryParts ? (layout.boundaryParts.length ? layout.boundaryParts : [boundary]) : [boundary];\n    var interiors = layout.interiors ? (layout.interiors.length ? layout.interiors : (layout.interior ? [layout.interior] : [])) : (layout.interior ? [layout.interior] : []);\n    var hasHeadland = interiors.length > 0 ? interiors[0] !== boundary : false;\n    \/\/ Terrain heatmap behind everything\n    drawTerrain(proj);\n    \/\/ Headland strip = boundary minus interior (even-odd fill), per part.\n    \/\/ Self-intersect guard (rule \u00a76): on irregular boundaries with sharp\n    \/\/ narrow corners (lv Landgut at 36 m headland on a pointy upper-\n    \/\/ left tip), the inward-offset polygon folds back on itself \u2192 the\n    \/\/ even-odd fill renders an X-pattern where the inset has crossed\n    \/\/ itself. Skip the strip fill for any part whose interior self-\n    \/\/ intersects so the user sees a clean field outline + body passes\n    \/\/ instead of a broken strip. Reported 2026-06-03.\n    if(hasHeadland){\n      for(var hp=0; hp<parts.length; hp++){\n        var hPart = parts[hp];\n        var hInner = interiors[hp] || hPart;\n        if(hInner === hPart) continue;  \/\/ no headland for this part\n        if(typeof polygonSelfIntersects === 'function' ? polygonSelfIntersects(hInner) : false) continue;\n        ctx.save();\n        ctx.fillStyle = 'rgba(26,121,81,0.18)';\n        ctx.beginPath();\n        for(var i=0; i<hPart.length; i++){\n          var bx = px(proj, hPart[i].x);\n          var by = py(proj, hPart[i].y);\n          if(i === 0) ctx.moveTo(bx, by); else ctx.lineTo(bx, by);\n        }\n        ctx.closePath();\n        for(var j=hInner.length-1; j>=0; j--){\n          var ix = px(proj, hInner[j].x);\n          var iy = py(proj, hInner[j].y);\n          if(j === hInner.length-1) ctx.moveTo(ix, iy); else ctx.lineTo(ix, iy);\n        }\n        ctx.closePath();\n        ctx.fill('evenodd');\n        ctx.restore();\n      }\n    }\n    \/\/ Drivable outline (only if outsideBuffer > 0 \u2192 drivable \u2260 boundary)\n    if(layout.drivable ? layout.drivable !== boundary : false){\n      ctx.save();\n      ctx.strokeStyle = 'rgba(20,83,40,0.35)';\n      ctx.setLineDash([4, 4]);\n      ctx.lineWidth = 1;\n      ctx.beginPath();\n      for(var dv=0; dv<layout.drivable.length; dv++){\n        var dvx = px(proj, layout.drivable[dv].x);\n        var dvy = py(proj, layout.drivable[dv].y);\n        if(dv === 0) ctx.moveTo(dvx, dvy); else ctx.lineTo(dvx, dvy);\n      }\n      ctx.closePath();\n      ctx.stroke();\n      ctx.restore();\n    }\n    \/\/ Field boundary \u2014 one stroke per part\n    ctx.save();\n    ctx.strokeStyle = '#145328';\n    ctx.lineWidth = 2;\n    ctx.lineJoin = 'round';\n    for(var bp=0; bp<parts.length; bp++){\n      var bPart = parts[bp];\n      ctx.beginPath();\n      for(var k=0; k<bPart.length; k++){\n        var bxF = px(proj, bPart[k].x);\n        var byF = py(proj, bPart[k].y);\n        if(k === 0) ctx.moveTo(bxF, byF); else ctx.lineTo(bxF, byF);\n      }\n      ctx.closePath();\n      ctx.stroke();\n    }\n    ctx.restore();\n    \/\/ Obstacle \/ hole outlines \u2014 drawn as a hatched no-work zone so the\n    \/\/ operator reads them as \"drive around, don't enter\". A muted fill +\n    \/\/ dark dashed edge distinguishes them from the field boundary.\n    var anyHole = false;\n    for(var hpC=0; hpC<parts.length; hpC++){ if(parts[hpC].holes ? parts[hpC].holes.length > 0 : false){ anyHole = true; break; } }\n    if(anyHole){\n      ctx.save();\n      for(var hp=0; hp<parts.length; hp++){\n        var hParts = parts[hp].holes;\n        if(!hParts ? true : hParts.length === 0) continue;\n        for(var hi=0; hi<hParts.length; hi++){\n          var hr = hParts[hi];\n          if(!hr ? true : hr.length < 3) continue;\n          ctx.beginPath();\n          for(var hk=0; hk<hr.length; hk++){\n            var hxF = px(proj, hr[hk].x);\n            var hyF = py(proj, hr[hk].y);\n            if(hk === 0) ctx.moveTo(hxF, hyF); else ctx.lineTo(hxF, hyF);\n          }\n          ctx.closePath();\n          \/\/ Muted grey-green fill so the cut-out reads as a no-work zone.\n          ctx.fillStyle = 'rgba(76,96,102,0.16)';\n          ctx.fill();\n          ctx.strokeStyle = 'rgba(76,96,102,0.85)';\n          ctx.lineWidth = 1.4;\n          ctx.setLineDash([5, 3]);\n          ctx.lineJoin = 'round';\n          ctx.stroke();\n          ctx.setLineDash([]);\n        }\n      }\n      ctx.restore();\n    }\n    \/\/ Interior outline (inner edge of headland strip) \u2014 one per part.\n    \/\/ Rule \u00a76 \u2014 skip the stroke when the inset polygon self-intersects.\n    \/\/ On sharp\/acute boundary tips (lv Landgut upper-left V), the\n    \/\/ inward-offset folds back on itself near the apex; drawing the\n    \/\/ raw polyline shows that fold as crossing green lines (reported\n    \/\/ 2026-06-04). The headland-strip fill already has the same\n    \/\/ guard one block above; the stroke needs it too.\n    if(hasHeadland){\n      for(var ip2=0; ip2<interiors.length; ip2++){\n        var inner = interiors[ip2];\n        if(typeof polygonSelfIntersects === 'function' ? polygonSelfIntersects(inner) : false) continue;\n        ctx.save();\n        ctx.strokeStyle = '#1a7951';\n        ctx.lineWidth = 1.5;\n        ctx.beginPath();\n        for(var rp=0; rp<inner.length; rp++){\n          var rx = px(proj, inner[rp].x);\n          var ry = py(proj, inner[rp].y);\n          if(rp === 0) ctx.moveTo(rx, ry); else ctx.lineTo(rx, ry);\n        }\n        ctx.closePath();\n        ctx.stroke();\n        ctx.restore();\n      }\n    }\n    \/\/ Rule \u00a71 \u2014 the machine must NEVER leave the field boundary. Clip every\n    \/\/ fat-line stroke (swath, compaction wash, passes, turn arcs) to the\n    \/\/ boundary so concave \/ curved edges don't visually overshoot. Transport\n    \/\/ legs between disconnected parts are drawn OUTSIDE this clip because\n    \/\/ they explicitly cross the gap between two parts.\n    var boundaryPath = buildBoundaryPath(proj, parts);\n    ctx.save();\n    ctx.clip(boundaryPath, 'evenodd');\n    \/\/ Uploaded existing guidance lines \u2014 drawn UNDER the proposed plan so\n    \/\/ the user can compare what they're doing today vs what we suggest.\n    \/\/ Semi-transparent blue, dashed, so they don't fight visually with the\n    \/\/ orange proposed swath painted on top.\n    if(UPLOADED_LINES ? UPLOADED_LINES.length > 0 : false){\n      ctx.save();\n      ctx.lineCap = 'round';\n      \/\/ Distinguish headland-ring traces (closed loops) from body\n      \/\/ passes so the user can SEE that imported headlands made it\n      \/\/ through. Body passes: dashed thin blue. Headland traces:\n      \/\/ SOLID thicker blue, no dashes \u2014 looks like an actual driven\n      \/\/ perimeter (matches what QGIS \/ John Deere Ops renders).\n      \/\/ Reported 2026-06-04: \"when import existing lines, you need\n      \/\/ also to import headland lines, import all lines\".\n      for(var ul=0; ul<UPLOADED_LINES.length; ul++){\n        var uln = UPLOADED_LINES[ul];\n        if(uln.length < 2) continue;\n        var isLoop = (typeof isImportedLineLoop === 'function') ? isImportedLineLoop(uln) : false;\n        if(isLoop){\n          ctx.strokeStyle = 'rgba(33,102,200,0.85)';\n          ctx.lineWidth = 2.2;\n          ctx.setLineDash([]);\n        } else {\n          ctx.strokeStyle = 'rgba(33,102,200,0.55)';\n          ctx.lineWidth = 1.6;\n          ctx.setLineDash([6, 4]);\n        }\n        ctx.beginPath();\n        ctx.moveTo(px(proj, uln[0].x), py(proj, uln[0].y));\n        for(var uv=1; uv<uln.length; uv++){\n          ctx.lineTo(px(proj, uln[uv].x), py(proj, uln[uv].y));\n        }\n        ctx.stroke();\n      }\n      ctx.restore();\n    }\n    \/\/ Soil compaction zones (always-visible violet glow under each turn arc) \u2014\n    \/\/ skip transport arcs (no work happens during a transport hop).\n    drawCompactionZones(proj, layout);\n    \/\/ Swath coverage band (filled as playback progresses) \u2014 also skips\n    \/\/ transport segments (drawSwath checks point.transport).\n    drawSwath(proj);\n    \/\/ Passes (the worked guidance lines)\n    ctx.strokeStyle = '#f76a0c';\n    ctx.lineWidth = 1.2;\n    ctx.lineCap = 'round';\n    ctx.setLineDash([]);\n    for(var p=0; p<layout.passes.length; p++){\n      var pa = layout.passes[p];\n      ctx.beginPath();\n      if(pa.samples){\n        for(var ps=0; ps<pa.samples.length; ps++){\n          var ssx = px(proj, pa.samples[ps].x);\n          var ssy = py(proj, pa.samples[ps].y);\n          if(ps === 0) ctx.moveTo(ssx, ssy); else ctx.lineTo(ssx, ssy);\n        }\n      } else {\n        ctx.moveTo(px(proj, pa.x0), py(proj, pa.y0));\n        ctx.lineTo(px(proj, pa.x1), py(proj, pa.y1));\n      }\n      ctx.stroke();\n    }\n    ctx.restore();  \/\/ closes the boundary clip\n    \/\/ Turn arcs are rendered OUTSIDE the boundary clip on purpose \u2014 U-turn\n    \/\/ arcs intentionally bulge outward into the headland strip and the\n    \/\/ drivable buffer, and the boundary clip was previously truncating them\n    \/\/ (issue: \"some U-turns are not shown\"). The machine's IMPLEMENT is\n    \/\/ lifted on turns, so it can extend past the worked-area edge; the\n    \/\/ boundary stroke + buffer dashed ring still mark the field outline.\n    if(layout.turnArcs ? layout.turnArcs.length : false){\n      ctx.save();\n      ctx.lineCap = 'round';\n      ctx.lineJoin = 'round';\n      for(var ti=0; ti<layout.turnArcs.length; ti++){\n        var arc = layout.turnArcs[ti];\n        if(arc.kind === 'transport') continue;  \/\/ drawn separately below\n        var isTraverse = arc.kind === 'traverse';\n        var isRingRoute = arc.kind === 'ring-route';\n        var isRingJump = arc.kind === 'ring-jump';\n        if(isTraverse){\n          ctx.strokeStyle = '#a21caf';\n          ctx.lineWidth = 1.8;\n          ctx.setLineDash([6, 4]);\n        } else if(isRingRoute){\n          ctx.strokeStyle = '#a21caf';\n          ctx.lineWidth = 1.6;\n          ctx.setLineDash([3, 3]);\n        } else if(isRingJump){\n          \/\/ Ring-to-ring radial transit (Boundary Follow, multi-ring headland).\n          \/\/ Visualised as a thinner dashed violet so the user sees the machine\n          \/\/ physically moving from one ring to the next instead of teleporting.\n          ctx.strokeStyle = '#a21caf';\n          ctx.lineWidth = 1.4;\n          ctx.setLineDash([4, 3]);\n        } else {\n          ctx.strokeStyle = arc.ok ? '#a21caf' : '#dc2626';\n          ctx.lineWidth = arc.ok ? 2 : 1.4;\n          ctx.setLineDash(arc.ok ? [] : [3, 3]);\n        }\n        ctx.beginPath();\n        for(var ac=0; ac<arc.coords.length; ac++){\n          var aex = px(proj, arc.coords[ac].x);\n          var aey = py(proj, arc.coords[ac].y);\n          if(ac === 0) ctx.moveTo(aex, aey); else ctx.lineTo(aex, aey);\n        }\n        ctx.stroke();\n      }\n      ctx.restore();\n    }\n    \/\/ Transport legs between disconnected parts \u2014 drawn OUTSIDE the clip\n    \/\/ because they cross the gap between two field parts. Gray dashed line\n    \/\/ only; the dash pattern already reads as \"no work here\" (the text\n    \/\/ pill was dropped 2026-06-11 per user feedback \u2014 it cluttered the\n    \/\/ canvas, especially on multi-part fields with several hops).\n    if(layout.turnArcs ? layout.turnArcs.length : false){\n      ctx.save();\n      ctx.strokeStyle = '#4c6066';\n      ctx.lineWidth = 1.6;\n      ctx.lineCap = 'round';\n      ctx.setLineDash([8, 6]);\n      for(var tt=0; tt<layout.turnArcs.length; tt++){\n        var tArc = layout.turnArcs[tt];\n        if(tArc.kind !== 'transport') continue;\n        if(!tArc.coords ? true : tArc.coords.length < 2) continue;\n        ctx.beginPath();\n        ctx.moveTo(px(proj, tArc.coords[0].x), py(proj, tArc.coords[0].y));\n        for(var tc=1; tc<tArc.coords.length; tc++){\n          ctx.lineTo(px(proj, tArc.coords[tc].x), py(proj, tArc.coords[tc].y));\n        }\n        ctx.stroke();\n      }\n      ctx.restore();\n    }\n    \/\/ Direction arrows on passes + turn arcs (above lines, below sprite)\n    drawDirectionArrows(proj, layout);\n    \/\/ Tractor sprite on top\n    drawSprite(proj);\n    \/\/ Scale bar \u2014 pick a nice round metres value for ~60 px on screen and\n    \/\/ adjust the bar width to match the chosen value at current proj.sc.\n    var scaleBarEl = document.getElementById('gpl-scale');\n    var scaleLblEl = document.getElementById('gpl-scale-lbl');\n    if(scaleBarEl ? scaleLblEl : false){\n      var targetPx = 60;\n      var rawMetres = targetPx \/ proj.sc;\n      \/\/ Snap to a 1\/2\/5 \u00d7 10\u207f pattern (standard cartographic scale steps)\n      var pow10 = Math.pow(10, Math.floor(Math.log(rawMetres) \/ Math.LN10));\n      var snapVal;\n      var lead = rawMetres \/ pow10;\n      if(lead < 1.5) snapVal = 1 * pow10;\n      else if(lead < 3.5) snapVal = 2 * pow10;\n      else if(lead < 7.5) snapVal = 5 * pow10;\n      else snapVal = 10 * pow10;\n      var barPx = snapVal * proj.sc;\n      var barEl = scaleBarEl.querySelector('.gpl-scale-bar');\n      if(barEl) barEl.style.width = barPx.toFixed(0) + 'px';\n      scaleLblEl.textContent = fmtDist(snapVal);\n    }\n    \/\/ Ruler line on absolute top\n    if(ruler.p1 ? ruler.p2 : false){\n      ctx.save();\n      ctx.strokeStyle = '#dc2626';\n      ctx.lineWidth = 2;\n      ctx.setLineDash([6, 4]);\n      ctx.beginPath();\n      ctx.moveTo(px(proj, ruler.p1.x), py(proj, ruler.p1.y));\n      ctx.lineTo(px(proj, ruler.p2.x), py(proj, ruler.p2.y));\n      ctx.stroke();\n      ctx.restore();\n      \/\/ Endpoint dots\n      ctx.save();\n      ctx.fillStyle = '#dc2626';\n      ctx.beginPath(); ctx.arc(px(proj, ruler.p1.x), py(proj, ruler.p1.y), 4, 0, Math.PI*2); ctx.fill();\n      ctx.beginPath(); ctx.arc(px(proj, ruler.p2.x), py(proj, ruler.p2.y), 4, 0, Math.PI*2); ctx.fill();\n      ctx.restore();\n      \/\/ Distance label\n      var rdx = ruler.p2.x - ruler.p1.x;\n      var rdy = ruler.p2.y - ruler.p1.y;\n      var rDist = Math.sqrt(rdx*rdx + rdy*rdy);\n      var labelMx = px(proj, (ruler.p1.x + ruler.p2.x) * 0.5);\n      var labelMy = py(proj, (ruler.p1.y + ruler.p2.y) * 0.5);\n      ctx.save();\n      ctx.font = '700 12px \"DM Mono\", ui-monospace, monospace';\n      var label = fmtDist(rDist);\n      var tw = ctx.measureText(label).width + 14;\n      ctx.fillStyle = 'rgba(220,38,38,0.95)';\n      ctx.fillRect(labelMx - tw \/ 2, labelMy - 11, tw, 22);\n      ctx.fillStyle = '#fff';\n      ctx.textAlign = 'center';\n      ctx.textBaseline = 'middle';\n      ctx.fillText(label, labelMx, labelMy);\n      ctx.restore();\n    } else if(ruler.p1){\n      ctx.save();\n      ctx.fillStyle = '#dc2626';\n      ctx.beginPath(); ctx.arc(px(proj, ruler.p1.x), py(proj, ruler.p1.y), 5, 0, Math.PI*2); ctx.fill();\n      ctx.restore();\n    }\n    \/\/ User-drawn AB line (the vector of main path). Persists after the tool\n    \/\/ exits so the user keeps seeing the direction they locked. Brand orange\n    \/\/ dashed line with a solid arrowhead at p2 and an \"AB\" label near the\n    \/\/ midpoint so it's distinguishable from the ruler's red dashed line.\n    if(abTool.p1 ? abTool.p2 : false){\n      ctx.save();\n      ctx.strokeStyle = '#f76a0c';\n      ctx.lineWidth = 2.5;\n      ctx.setLineDash([8, 4]);\n      ctx.lineCap = 'round';\n      var ax1 = px(proj, abTool.p1.x), ay1 = py(proj, abTool.p1.y);\n      var ax2 = px(proj, abTool.p2.x), ay2 = py(proj, abTool.p2.y);\n      ctx.beginPath();\n      ctx.moveTo(ax1, ay1);\n      ctx.lineTo(ax2, ay2);\n      ctx.stroke();\n      ctx.setLineDash([]);\n      \/\/ Endpoint markers (start hollow, end solid arrow)\n      ctx.fillStyle = '#ffffff';\n      ctx.beginPath(); ctx.arc(ax1, ay1, 5, 0, Math.PI * 2); ctx.fill();\n      ctx.strokeStyle = '#f76a0c';\n      ctx.lineWidth = 2;\n      ctx.stroke();\n      \/\/ Arrowhead at p2 \u2014 solid filled triangle along the vector direction\n      var hdx = ax2 - ax1, hdy = ay2 - ay1;\n      var hlen = Math.sqrt(hdx * hdx + hdy * hdy);\n      if(hlen > 1){\n        var ux = hdx \/ hlen, uy = hdy \/ hlen;\n        var arrowLen = 12, arrowWide = 8;\n        var tipX = ax2, tipY = ay2;\n        var baseX = ax2 - ux * arrowLen, baseY = ay2 - uy * arrowLen;\n        var leftX = baseX + (-uy) * arrowWide * 0.5;\n        var leftY = baseY + (ux) * arrowWide * 0.5;\n        var rightX = baseX - (-uy) * arrowWide * 0.5;\n        var rightY = baseY - (ux) * arrowWide * 0.5;\n        ctx.fillStyle = '#f76a0c';\n        ctx.beginPath();\n        ctx.moveTo(tipX, tipY);\n        ctx.lineTo(leftX, leftY);\n        ctx.lineTo(rightX, rightY);\n        ctx.closePath();\n        ctx.fill();\n      }\n      \/\/ Label \"AB\" near midpoint\n      var midX = (ax1 + ax2) * 0.5;\n      var midY = (ay1 + ay2) * 0.5;\n      ctx.font = '700 11px \"DM Mono\", ui-monospace, monospace';\n      var lblW = ctx.measureText('AB').width + 12;\n      ctx.fillStyle = 'rgba(247,106,12,0.95)';\n      ctx.fillRect(midX - lblW \/ 2, midY - 10, lblW, 20);\n      ctx.fillStyle = '#fff';\n      ctx.textAlign = 'center';\n      ctx.textBaseline = 'middle';\n      ctx.fillText('AB', midX, midY);\n      ctx.restore();\n    } else if(abTool.p1){\n      ctx.save();\n      ctx.fillStyle = '#f76a0c';\n      ctx.beginPath(); ctx.arc(px(proj, abTool.p1.x), py(proj, abTool.p1.y), 5, 0, Math.PI * 2); ctx.fill();\n      ctx.restore();\n    }\n  }\n  var current = 'ab-straight';\n  \/\/ Last metric-driven best pick, set by recompute() after recommendByMetrics\n  \/\/ runs. Read by autoPickBestApproach() to decide whether to swap the\n  \/\/ current approach on field-load + initial-page-load.\n  var lastBestPick = null;\n  \/\/ True while autoPickBestApproach is actively switching approaches \u2014\n  \/\/ prevents recursive auto-pick if recompute() re-fires recommendByMetrics.\n  var autoPickInFlight = false;\n  \/\/ True ONLY between (a) initial-page-load or (b) field-switch and the\n  \/\/ first deferred-block autoPickBestApproach call. Cleared after the\n  \/\/ swap fires (or determines no swap is needed). Prevents the deferred\n  \/\/ block + axis sweep from overriding the user's explicit approach choice\n  \/\/ when they manually click a radio button (reported 2026-06-04 \u2014 \"I\n  \/\/ can't select any other approach since it automatically selects the\n  \/\/ best\"). Manual radio clicks clear current to the user's choice\n  \/\/ WITHOUT setting this flag, so subsequent recomputes don't swap back.\n  var pendingAutoPick = false;\n  \/\/ After recompute settles, if the metric-driven best differs from the\n  \/\/ currently-selected approach, swap to it and re-run recompute once.\n  \/\/ Used on (a) initial page load and (b) field-switch \u2014 NOT used after a\n  \/\/ user clicks an approach radio (that's an explicit user choice we honour).\n  function autoPickBestApproach(){\n    if(autoPickInFlight) return;\n    if(!pendingAutoPick) return;  \/\/ Manual radio clicks are sticky\n    if(!lastBestPick){ pendingAutoPick = false; return; }\n    if(lastBestPick === current){ pendingAutoPick = false; return; }\n    autoPickInFlight = true;\n    pendingAutoPick = false;  \/\/ One auto-pick per field-switch \/ initial-load\n    current = lastBestPick;\n    var rs = document.querySelectorAll('#gpl-approach input[type=radio]');\n    var ls = document.querySelectorAll('#gpl-approach label');\n    for(var ix=0; ix<rs.length; ix++){\n      var mt = rs[ix].value === current;\n      rs[ix].checked = mt;\n      if(ls[ix]) ls[ix].classList.toggle('is-on', mt);\n    }\n    try { recompute(); } finally { autoPickInFlight = false; }\n  }\n  \/\/ Plan-view toggle: 'proposed' = play + analyse the algorithm-\n  \/\/ generated layout; 'uploaded' = play + analyse the user's uploaded\n  \/\/ existing guidance lines. Auto-resets to 'proposed' when no upload.\n  \/\/ Legacy. 'uploaded' is now a first-class approach value, so this\n  \/\/ variable is unused at runtime \u2014 kept here as a hint for future\n  \/\/ editors that the upload-as-view mode used to live in a sibling\n  \/\/ toggle.\n  var planView = 'proposed';\n  \/\/ Build a layout that mirrors the generateLines return shape but\n  \/\/ sources its passes from UPLOADED_LINES. Drives the playback by\n  \/\/ chaining lines sequentially with short transport hops between\n  \/\/ them (operator finished line A, lifted, drove to start of line B).\n  function buildUploadedLayout(){\n    if(!UPLOADED_LINES ? true : UPLOADED_LINES.length === 0) return null;\n    \/\/ Separate closed-loop headland-ring traces from body passes.\n    \/\/ Both are DRIVEN by the operator (closed loops = perimeter pass);\n    \/\/ they just need different `kind` tags so the planner treats them\n    \/\/ correctly (rings = headland strip, body = serpentine). Earlier\n    \/\/ revision filtered loops out entirely \u2192 headland uncovered in\n    \/\/ playback + coverage metric undercounted (reported 2026-06-04).\n    var allDriven = drivenLinesOnly(UPLOADED_LINES);\n    var drivenLines = [];\n    var ringLines = [];\n    for(var sli=0; sli<allDriven.length; sli++){\n      if(isImportedLineLoop(allDriven[sli])) ringLines.push(allDriven[sli]);\n      else drivenLines.push(allDriven[sli]);\n    }\n    if(drivenLines.length === 0 ? ringLines.length === 0 : false) return null;\n    \/\/ SERPENTINE INFERENCE \u2014 real operators alternate pass direction\n    \/\/ (pass 0 L\u2192R, pass 1 R\u2192L, pass 2 L\u2192R \u2026) so each U-turn is short\n    \/\/ (~ one swath). But many export files store passes ALL in the\n    \/\/ same coordinate order (sorted by perp position). Iterating in\n    \/\/ file order then produces 200 m diagonal gaps between consecutive\n    \/\/ endpoints \u2014 which mis-classify as transports + miss the turn\n    \/\/ count entirely. Greedy fix: keep pass 0 as-is, then for each\n    \/\/ subsequent pass pick the orientation (forward or reversed) that\n    \/\/ minimises the gap to the previous endpoint.\n    var orderedLines = [];\n    if(drivenLines.length > 0){\n      orderedLines.push(drivenLines[0].slice());\n      orderedLines[0].driven = true;\n    }\n    for(var olp=1; olp<drivenLines.length; olp++){\n      var prevEnd = orderedLines[olp - 1][orderedLines[olp - 1].length - 1];\n      var next = drivenLines[olp];\n      var nFirst = next[0];\n      var nLast = next[next.length - 1];\n      var dFwd = (nFirst.x - prevEnd.x) * (nFirst.x - prevEnd.x) + (nFirst.y - prevEnd.y) * (nFirst.y - prevEnd.y);\n      var dRev = (nLast.x - prevEnd.x) * (nLast.x - prevEnd.x) + (nLast.y - prevEnd.y) * (nLast.y - prevEnd.y);\n      var oriented;\n      if(dRev < dFwd){\n        oriented = next.slice().reverse();\n      } else {\n        oriented = next.slice();\n      }\n      oriented.driven = true;\n      orderedLines.push(oriented);\n    }\n    drivenLines = orderedLines;\n    var upPasses = [];\n    for(var ui=0; ui<drivenLines.length; ui++){\n      upPasses.push({ samples: drivenLines[ui], kind: 'uploaded' });\n    }\n    \/\/ SYNTHETIC TURN ARCS \u2014 uploaded line files only record the BODY\n    \/\/ passes; the operator's actual U-turns between passes aren't in\n    \/\/ the file. But the machine physically MUST turn around between\n    \/\/ each pair of consecutive passes (operator + tractor can't\n    \/\/ teleport). Reported 2026-06-03: \"Your current lines \u2014 if there\n    \/\/ are no turns in lines itself, turns are still there, machine\n    \/\/ can't work w\/o turnarounds\".\n    \/\/\n    \/\/ Synthesise one turn arc per consecutive pass pair:\n    \/\/   \u2022 length = max(straight-line gap, \u03c0 \u00d7 turnR)  \u2014 at least one\n    \/\/     half-circle at the configured turn radius, longer if the\n    \/\/     consecutive endpoints are far apart.\n    \/\/   \u2022 kind = 'turn' so it counts in nTurns + turnLengthM.\n    \/\/   \u2022 kind = 'transport' (skipped from turn count) when the gap\n    \/\/     exceeds ~6 \u00d7 wM \u2014 that's a field-to-field hop, not a turn.\n    \/\/   \u2022 coords = a 3-vertex V-shape polyline with the right total\n    \/\/     length (we don't render uploaded turns on canvas, but the\n    \/\/     coords MUST sum to the intended length for computeMetrics).\n    var wmInpEl = document.getElementById('gpl-wm');\n    var wMRef = parseFloat((wmInpEl || {}).value) || 18;\n    if(unitSystem === 'us') wMRef = wMRef \/ M_TO_FT;\n    var trInpEl = document.getElementById('gpl-turn-r');\n    var turnRRef = parseFloat((trInpEl || {}).value) || 8;\n    if(unitSystem === 'us') turnRRef = turnRRef \/ M_TO_FT;\n    var minTurnLen = Math.PI * turnRRef;\n    var transportGapM = 6 * wMRef;\n    var turnArcs = [];\n    function buildSyntheticArc(p1, p2, minLen){\n      var dx = p2.x - p1.x, dy = p2.y - p1.y;\n      var gap = Math.sqrt(dx * dx + dy * dy);\n      if(gap < 0.001) return [{x: p1.x, y: p1.y}, {x: p2.x, y: p2.y}];\n      \/\/ Half-circle U-turn polyline matching what buildSerpentine\n      \/\/ produces for the proposed approaches. Radius = max(gap\/2, turnR)\n      \/\/ so the chord fits inside the circle. Bulges in the +perp\n      \/\/ direction (away from the chord). 12 sample points = smooth curve\n      \/\/ + lets countDriveCrossings see the actual arc geometry (so\n      \/\/ crossings count fairly when arcs bulge into adjacent passes).\n      \/\/ Previously the synthetic was a single bump-vertex V-shape \u2014\n      \/\/ didn't cross anything, made \"Your current lines\" look cleaner\n      \/\/ than it would in a real comparison (reported 2026-06-03).\n      var midX = (p1.x + p2.x) * 0.5;\n      var midY = (p1.y + p2.y) * 0.5;\n      var perpX = -dy \/ gap, perpY = dx \/ gap;\n      var radius = Math.max(gap * 0.5, minLen \/ Math.PI);\n      var halfGap = gap * 0.5;\n      var centerOff = radius > halfGap ? Math.sqrt(radius * radius - halfGap * halfGap) : 0;\n      \/\/ Arc center is centerOff away from midpoint, on the -perp side\n      \/\/ so the arc itself bulges +perp.\n      var cxA = midX - perpX * centerOff;\n      var cyA = midY - perpY * centerOff;\n      var a1 = Math.atan2(p1.y - cyA, p1.x - cxA);\n      var a2 = Math.atan2(p2.y - cyA, p2.x - cxA);\n      \/\/ Walk from a1 to a2 the LONG way (passing through +perp side).\n      var sweep = a2 - a1;\n      while(sweep < 0) sweep += 2 * Math.PI;\n      \/\/ The midpoint of the arc going CCW from a1 to a2 lands at angle\n      \/\/ a1 + sweep\/2. If that midpoint sits on the -perp side (closer\n      \/\/ to center, i.e. the wrong side), flip direction.\n      var midAng = a1 + sweep * 0.5;\n      var midSampleX = cxA + radius * Math.cos(midAng);\n      var midSampleY = cyA + radius * Math.sin(midAng);\n      var bulgeDot = (midSampleX - midX) * perpX + (midSampleY - midY) * perpY;\n      if(bulgeDot < 0){ sweep = sweep - 2 * Math.PI; }  \/\/ go the other way\n      var nSteps = 12;\n      var coords = [];\n      for(var k=0; k<=nSteps; k++){\n        var t = k \/ nSteps;\n        var theta = a1 + sweep * t;\n        coords.push({ x: cxA + radius * Math.cos(theta), y: cyA + radius * Math.sin(theta) });\n      }\n      return coords;\n    }\n    for(var tai=0; tai<upPasses.length - 1; tai++){\n      var prevLine = upPasses[tai].samples;\n      var nextLine = upPasses[tai + 1].samples;\n      if(!prevLine ? true : prevLine.length < 1) continue;\n      if(!nextLine ? true : nextLine.length < 1) continue;\n      var endPrev = prevLine[prevLine.length - 1];\n      var startNext = nextLine[0];\n      var dgx = startNext.x - endPrev.x, dgy = startNext.y - endPrev.y;\n      var rawGap = Math.sqrt(dgx * dgx + dgy * dgy);\n      var arcKind = rawGap > transportGapM ? 'transport' : 'turn';\n      \/\/ For transport hops keep a straight line; for turns build a\n      \/\/ proper half-circle (matches buildSerpentine's geometry \u2014 fair\n      \/\/ crossings comparison vs proposed approaches).\n      var coords;\n      if(arcKind === 'transport'){\n        coords = [{ x: endPrev.x, y: endPrev.y }, { x: startNext.x, y: startNext.y }];\n      } else {\n        coords = buildSyntheticArc(endPrev, startNext, minTurnLen);\n      }\n      turnArcs.push({ coords: coords, kind: arcKind });\n    }\n    \/\/ Push the closed-loop headland-ring traces FIRST in the playback\n    \/\/ sequence \u2014 operator drives the perimeter, THEN the body (rule \u00a713\n    \/\/ \"work headland first\"). Coverage now includes the ring swath.\n    for(var rli=0; rli<ringLines.length; rli++){\n      upPasses.push({ samples: ringLines[rli], kind: 'headland-ring' });\n    }\n    var driveCoords = [];\n    \/\/ 1) Headland rings (closed loops) \u2014 drive each loop fully before\n    \/\/    transporting to the next ring or to the first body pass.\n    for(var rdi=0; rdi<ringLines.length; rdi++){\n      var rline = ringLines[rdi];\n      if(!rline ? true : rline.length < 3) continue;\n      if(driveCoords.length > 0){\n        var lastPR = driveCoords[driveCoords.length - 1];\n        var firstPR = rline[0];\n        for(var trI=1; trI<=10; trI++){\n          var trT = trI \/ 10;\n          driveCoords.push({\n            x: lastPR.x + (firstPR.x - lastPR.x) * trT,\n            y: lastPR.y + (firstPR.y - lastPR.y) * trT,\n            transport: true\n          });\n        }\n      }\n      for(var rvi=0; rvi<rline.length; rvi++){\n        driveCoords.push({ x: rline[rvi].x, y: rline[rvi].y });\n      }\n    }\n    \/\/ 2) Body passes (with synthetic turn arcs interleaved \u2014 already in\n    \/\/    turnArcs, so we just concat the pass coords with transport\n    \/\/    legs between non-consecutive lines).\n    for(var li=0; li<drivenLines.length; li++){\n      var line = drivenLines[li];\n      if(!line ? true : line.length < 2) continue;\n      if(driveCoords.length > 0){\n        var lastP = driveCoords[driveCoords.length - 1];\n        var firstP = line[0];\n        var nT = 10;\n        for(var ti=1; ti<=nT; ti++){\n          var tt = ti \/ nT;\n          driveCoords.push({\n            x: lastP.x + (firstP.x - lastP.x) * tt,\n            y: lastP.y + (firstP.y - lastP.y) * tt,\n            transport: true\n          });\n        }\n      }\n      for(var vi=0; vi<line.length; vi++){\n        driveCoords.push({ x: line[vi].x, y: line[vi].y });\n      }\n    }\n    return {\n      passes: upPasses,\n      interior: BOUNDARY,\n      drivable: BOUNDARY,\n      boundary: BOUNDARY,\n      boundaryParts: BOUNDARY_PARTS,\n      turnArcs: turnArcs,\n      drivePath: driveCoords,\n      warning: null,\n      orderedPasses: upPasses,\n      isUploadedView: true\n    };\n  }\n  \/\/ Machine type \u2192 realistic MINIMUM turn radius (m, centerline) + typical\n  \/\/ implement width + a per-class scaling factor used by autoTurnRadius. The\n  \/\/ values target the LOWER end of each class's realistic range \u2014 the bare\n  \/\/ tractor's geometric pivot rather than the \"effective\" radius including\n  \/\/ wide implement swing. Two reasons:\n  \/\/   1) Visually cleaner U-turns. The half-circle uturn radius is chord\/2\n  \/\/      = wM\/2 (e.g. 7.5 m for a 15 m implement). When the user-set turn\n  \/\/      radius is \u2264 wM\/2 the natural half-circle gets picked and dominates\n  \/\/      the canvas. Larger radii kick the racetrack candidate in, which is\n  \/\/      uglier per rule \u00a76.\n  \/\/   2) Modern ag steering (front-axle hydraulic assist, rear-wheel steer,\n  \/\/      center-pivot articulation) makes the bare tractor turn tighter\n  \/\/      than the implement swing alone would suggest. A skilled operator\n  \/\/      hits these tighter radii regularly.\n  \/\/ Trailed implements still need more room than mounted ones, so the\n  \/\/ factor reflects that.\n  var MACHINE_SPEC = {\n    'tractor-std':   { r:  6, w: 15, factor: 0.40, label: 'tractor + mounted implement' },\n    'tractor-large': { r: 11, w: 18, factor: 0.60, label: 'tractor + 24-row planter (trailed)' },\n    'sprayer':       { r:  7, w: 36, factor: 0.20, label: 'self-propelled sprayer' },\n    'combine':       { r:  6, w: 12, factor: 0.50, label: 'combine harvester' },\n    'articulated':   { r: 14, w: 24, factor: 0.60, label: 'articulated 4WD + air seeder' },\n    'custom':        { r:  0, w:  0, factor: 0.50, label: 'manual override' }\n  };\n  \/\/ Generic auto-fit (Custom-mode width change with no machine class picked):\n  \/\/ assume mounted-implement style (factor 0.50 \u2014 about half the implement\n  \/\/ width). Floor 5 m (smallest sensible bare-tractor pivot), cap 18 m (above\n  \/\/ that user should explicitly pick a trailed-implement preset).\n  function autoTurnRadius(wM, factor){\n    if(typeof factor !== 'number') factor = 0.50;  \/\/ mounted-implement default\n    var r = wM * factor;\n    if(r < 5) r = 5;          \/\/ tractor's own minimum geometric pivot\n    else if(r > 18) r = 18;   \/\/ cap for the generic case; trailed presets override\n    return Math.round(r * 2) \/ 2;  \/\/ 0.5 m step matches the input's step\n  }\n  \/\/ Back-compat alias (the smoke tests poke MACHINE_RADIUS).\n  var MACHINE_RADIUS = MACHINE_SPEC;\n  var CURRENCY_SYMBOL = { usd: '$', eur: '\u20ac' };\n  function getInputs(){\n    \/\/ Inputs are displayed in the active unit system but the geometry\n    \/\/ pipeline + cost math always run in metric (m, km, ha, L, $\/L). Read\n    \/\/ raw values and divide by the conversion factor when in US mode so the\n    \/\/ returned object is metric. setUnitSystem() handles the inverse trip\n    \/\/ (multiplying input values by the factor when the user toggles).\n    var rawWm = parseFloat(document.getElementById('gpl-wm').value) || 18;\n    var wM = unitSystem === 'us' ? rawWm \/ M_TO_FT : rawWm;\n    \/\/ Safety clamp \u2014 the wM input has min=3 in HTML but partial typing\n    \/\/ (\"1\" while the user is on their way to \"12\") would push hundreds of\n    \/\/ body passes through the pipeline and freeze the browser even with\n    \/\/ debounce, since the synchronous compute itself dominates. Floor at\n    \/\/ 6 m (smallest realistic farm-scale implement \u2014 most are 9 m+) so any\n    \/\/ in-flight value can't drag compute time out. The user's final value\n    \/\/ (>=6 m) is honoured; only sub-6 transients get clamped.\n    if(wM < 6) wM = 6;\n    if(wM > 60) wM = 60;\n    var hlMult = parseFloat(document.getElementById('gpl-hl-mult').value);\n    if(isNaN(hlMult)) hlMult = 1;\n    \/\/ Headland is ALWAYS N \u00d7 implement width (per user \u2014 custom override\n    \/\/ dropped, the multiplier alone defines the strip).\n    var headlandM = hlMult * wM;\n    var turnStyle = document.getElementById('gpl-turn-style').value || 'uturn';\n    var rawTurnR = parseFloat(document.getElementById('gpl-turn-r').value) || 6;\n    var turnR = unitSystem === 'us' ? rawTurnR \/ M_TO_FT : rawTurnR;\n    \/\/ turnBuf removed \u2014 auto-pullBack handles headland-tight cases.\n    var turnBuf = 0;\n    var machineEl = document.getElementById('gpl-machine');\n    var machineKey = machineEl ? machineEl.value : 'tractor-std';\n    var curEl = document.getElementById('gpl-currency');\n    var currency = curEl ? curEl.value : 'usd';\n    var farmEl = document.getElementById('gpl-roi-farm');\n    var appsEl = document.getElementById('gpl-roi-apps');\n    var rawFarm = farmEl ? parseFloat(farmEl.value) : 200;\n    if(isNaN(rawFarm) ? true : rawFarm <= 0) rawFarm = 200;\n    var farmHa = unitSystem === 'us' ? rawFarm \/ HA_TO_AC : rawFarm;\n    var apps = appsEl ? parseFloat(appsEl.value) : 4;\n    if(isNaN(apps) ? true : apps <= 0) apps = 4;\n    var rawDiesel = parseFloat(document.getElementById('gpl-fuel').value) || 1.2;\n    var dieselPerL = unitSystem === 'us' ? rawDiesel \/ LPL_TO_DPGAL : rawDiesel;\n    var rawCons = parseFloat(document.getElementById('gpl-cons').value) || 0.6;\n    var consLPerKm = unitSystem === 'us' ? rawCons \/ LPKM_TO_GPMI : rawCons;\n    return {\n      wM: wM,\n      diesel: dieselPerL,\n      cons: consLPerKm,\n      headlandM: headlandM,\n      headlandMult: hlMult,\n      turnStyle: turnStyle,\n      turnR: turnR,\n      turnBuf: turnBuf,\n      machine: machineKey,\n      currency: currency,\n      farmHa: farmHa,\n      apps: apps\n    };\n  }\n  \/\/ PERF \u2014 input bursts (slider drags 30-60\u00d7\/sec, typing in number inputs)\n  \/\/ each trigger a recompute that does ~30 generateLines passes (current\n  \/\/ + baseline + 3 other approaches + axis sweep). Unthrottled, that melts\n  \/\/ the main thread and freezes the browser when the user is mid-typing\n  \/\/ (e.g. on the way from \"12\" they pass through \"1\" which would emit\n  \/\/ thousands of passes without the wM clamp in getInputs).\n  \/\/\n  \/\/ scheduleRecompute:\n  \/\/   1) Debounces input bursts into ONE recompute per 250 ms (long enough\n  \/\/      that typing \"12\" doesn't recompute at \"1\").\n  \/\/   2) Shows the loader overlay AND yields one animation frame before\n  \/\/      starting the synchronous compute, so the browser actually paints\n  \/\/      the loader before the main thread freezes. Without this yield\n  \/\/      the loader stays invisible because the paint never gets scheduled\n  \/\/      between showLoader() and the start of recompute().\n  \/\/   3) Always hides the loader in a finally block so an exception in\n  \/\/      recompute can't leave the page stuck behind a spinner.\n  var recomputeTimer = null;\n  var loaderEl = null;  \/\/ resolved lazily \u2014 element may not exist on first call\n  var loaderSubEl = null;\n  function showLoader(msg){\n    if(!loaderEl) loaderEl = document.getElementById('gpl-loader');\n    if(!loaderSubEl) loaderSubEl = document.getElementById('gpl-loader-sub');\n    if(loaderEl){\n      if(msg ? loaderSubEl : false) loaderSubEl.textContent = msg;\n      loaderEl.classList.add('is-on');\n      loaderEl.setAttribute('aria-hidden', 'false');\n    }\n  }\n  function hideLoader(){\n    if(loaderEl){\n      loaderEl.classList.remove('is-on');\n      loaderEl.setAttribute('aria-hidden', 'true');\n      if(loaderSubEl) loaderSubEl.textContent = 'Planning guidance lines\u2026';\n    }\n  }\n  \/\/ Run a heavy synchronous task BEHIND the branded loader. Shows the\n  \/\/ loader, yields one paint frame (rAF + setTimeout 0) so the spinner\n  \/\/ actually appears before the main thread freezes, runs the task, then\n  \/\/ always hides the loader. Used for boundary import \u2014 projecting +\n  \/\/ assigning holes + the first full recompute on a big field can freeze\n  \/\/ the thread for ~1 s, and without this the user saw a frozen page with\n  \/\/ no \"calculating\" indication (reported 2026-06-15).\n  function runWithLoader(msg, fn){\n    showLoader(msg);\n    requestAnimationFrame(function(){\n      setTimeout(function(){\n        try { fn(); }\n        finally { hideLoader(); }\n      }, 0);\n    });\n  }\n  \/\/ True trailing-edge debounce. Every call CLEARS the previous pending\n  \/\/ timer and starts a fresh one \u2014 so the recompute fires `delay` ms after\n  \/\/ the LAST input event, not the first. Without clearTimeout the original\n  \/\/ code was a throttle: type \"1\", a timer was set, slow typers paused,\n  \/\/ and the timer fired with the partial value before \"2\" was even pressed.\n  \/\/ Two delay presets:\n  \/\/   500 ms \u2014 keystrokes in number inputs (Width, Turn r, Diesel, etc.).\n  \/\/            Two-digit values like \"12\" land within ~200 ms so the user\n  \/\/            sees ONE recompute at \"12\" instead of one at \"1\" + one at \"12\".\n  \/\/   120 ms \u2014 slider drags (AB direction, headland multiplier). Still feels\n  \/\/            live (~8 recomputes\/sec during a drag) without melting the\n  \/\/            main thread.\n  function scheduleRecompute(delay){\n    if(typeof delay !== 'number') delay = 500;\n    if(recomputeTimer){\n      clearTimeout(recomputeTimer);\n      recomputeTimer = null;\n    }\n    recomputeTimer = setTimeout(function(){\n      recomputeTimer = null;\n      showLoader();\n      requestAnimationFrame(function(){\n        \/\/ setTimeout(0) inside rAF puts the heavy work AFTER the next paint\n        \/\/ so the loader actually appears before we block the thread.\n        setTimeout(function(){\n          try { recompute(); }\n          finally { hideLoader(); }\n        }, 0);\n      });\n    }, delay);\n  }\n  \/\/ Slider-specific wrapper \u2014 sliders fire 30-60 input events\/sec so we\n  \/\/ want a much shorter debounce to keep drag feedback live. Anything that\n  \/\/ hands input through to scheduleRecomputeFast is treating that input as\n  \/\/ a continuous-drag stream rather than discrete typing.\n  function scheduleRecomputeFast(){ scheduleRecompute(120); }\n  \/\/ Axis sweep runs AFTER the main recompute settles. The sweep tries 6\n  \/\/ additional AB-line angles per approach (~28 extra layout builds) to\n  \/\/ find a higher-coverage axis on irregular fields. That work is too heavy\n  \/\/ to run synchronously inside every typed-input recompute \u2014 so the main\n  \/\/ recompute now just leaves a sweep request queued via this helper, and\n  \/\/ the sweep actually runs ~600 ms after the LAST recompute. If the user\n  \/\/ keeps typing\/dragging, the sweep keeps getting rescheduled and never\n  \/\/ runs until they pause; that means heavy typing feels snappy and the\n  \/\/ refined Compare All numbers + Best recommendation update once the user\n  \/\/ stops interacting.\n  var sweepTimer = null;\n  function scheduleAxisSweep(inp, axis, approaches, apMetrics, sym){\n    if(sweepTimer) clearTimeout(sweepTimer);\n    sweepTimer = setTimeout(function(){\n      sweepTimer = null;\n      runAxisSweep(inp, axis, approaches, apMetrics, sym);\n    }, 600);\n  }\n  function runAxisSweep(inp, axis, approaches, apMetrics, sym){\n    if(userAxisDeg !== null) return;  \/\/ user pinned an axis, sweep is moot\n    \/\/ Field-size-aware sweep granularity. The sweep is the heaviest\n    \/\/ recompute work (every angle \u00d7 every approach runs a full\n    \/\/ generateLinesAll + computeMetrics). On big real-world fields\n    \/\/ (~500 ha, 200+ passes per layout) the full 6-angle sweep across\n    \/\/ 5 approaches takes 4+ seconds of synchronous CPU \u2014 even debounced\n    \/\/ it locks the UI. Coarse fall-back: 3 angles only.\n    var stTotalVerts = 0;\n    for(var svp=0; svp<BOUNDARY_PARTS.length; svp++) stTotalVerts += BOUNDARY_PARTS[svp].length;\n    var stSpan = 0;\n    for(var svq=0; svq<BOUNDARY_PARTS.length; svq++){\n      var pq = BOUNDARY_PARTS[svq], mxX = -Infinity, mnX = Infinity, mxY = -Infinity, mnY = Infinity;\n      for(var svr=0; svr<pq.length; svr++){\n        if(pq[svr].x < mnX) mnX = pq[svr].x; if(pq[svr].x > mxX) mxX = pq[svr].x;\n        if(pq[svr].y < mnY) mnY = pq[svr].y; if(pq[svr].y > mxY) mxY = pq[svr].y;\n      }\n      var sp = Math.max(mxX - mnX, mxY - mnY);\n      if(sp > stSpan) stSpan = sp;\n    }\n    var heavyField = stSpan \/ inp.wM > 100 ? true : stTotalVerts > 100;\n    var sweepAngles = heavyField ? [0, 45] : [0, 15, 30, 45, 60, 75];\n    var pcaDeg = Math.round(Math.atan2(fieldAxis(BOUNDARY).uy, fieldAxis(BOUNDARY).ux) * 180 \/ Math.PI);\n    while(pcaDeg < 0) pcaDeg += 180;\n    while(pcaDeg >= 90) pcaDeg -= 90;\n    if(sweepAngles.indexOf(pcaDeg) < 0) sweepAngles.push(pcaDeg);\n    var bestApAxes = {};\n    for(var aps=0; aps<approaches.length; aps++){\n      var apS = approaches[aps];\n      var bestMet = apMetrics[apS];\n      var bestAng = null;\n      for(var sa=0; sa<sweepAngles.length; sa++){\n        var ang = sweepAngles[sa] * Math.PI \/ 180;\n        var trialAxis = { ux: Math.cos(ang), uy: Math.sin(ang) };\n        var trialLay = generateLinesAll(apS, inp.wM, BOUNDARY_PARTS, trialAxis, inp.headlandM, inp.turnStyle, inp.turnR, inp.turnBuf);\n        var trialMet = computeMetrics(trialLay, inp.wM, inp.diesel, inp.cons);\n        if((trialMet.coveragePct || 0) > (bestMet.coveragePct || 0)){\n          bestMet = trialMet;\n          bestAng = sweepAngles[sa];\n        }\n      }\n      if(bestAng !== null){\n        apMetrics[apS] = bestMet;\n        bestApAxes[apS] = bestAng;\n      }\n    }\n    \/\/ Refresh recommendation badges + bottom \"Best for this field\" panel\n    \/\/ with the swept-axis metrics. Both views read from the SAME source\n    \/\/ (recommendByMetrics(apMetrics)) so the left-panel BEST badge and\n    \/\/ the bottom table's recommendation stay in sync \u2014 previously the\n    \/\/ axis sweep updated badges only, leaving the bottom panel stale at\n    \/\/ the earlier laterReco pick (reported 2026-06-03).\n    var recoMetric = recommendByMetrics(apMetrics);\n    var recoBadges = document.querySelectorAll('.gpl-reco');\n    for(var rb=0; rb<recoBadges.length; rb++){\n      var rkey = recoBadges[rb].getAttribute('data-reco');\n      recoBadges[rb].classList.toggle('is-on', rkey === recoMetric.pick);\n    }\n    var recoSwBestName = document.getElementById('gpl-cmp-best-name');\n    var recoSwBestWhy = document.getElementById('gpl-cmp-best-why');\n    if(recoSwBestName) recoSwBestName.textContent = AP_LABELS[recoMetric.pick] || recoMetric.pick;\n    if(recoSwBestWhy) recoSwBestWhy.textContent = recoMetric.why || '';\n    var recoSwRows = document.querySelectorAll('#gpl-cmp-table .gpl-cmp-row');\n    for(var rsr=0; rsr<recoSwRows.length; rsr++) recoSwRows[rsr].classList.remove('is-best');\n    var recoSwBestRow = document.querySelector('#gpl-cmp-table .gpl-cmp-row[data-cmp=\"' + recoMetric.pick + '\"]');\n    if(recoSwBestRow) recoSwBestRow.classList.add('is-best');\n    var recoHintEl = document.getElementById('gpl-reco-hint');\n    if(recoHintEl){\n      var apLabel = AP_LABELS[recoMetric.pick] || 'Best';\n      var hintTxt = 'Best: ' + apLabel + ' \u2014 ' + recoMetric.why;\n      if(bestApAxes[recoMetric.pick] !== undefined){\n        hintTxt += ' \u00b7 best at ' + bestApAxes[recoMetric.pick] + '\u00b0';\n      }\n      var bestCov = (apMetrics[recoMetric.pick] || {}).coveragePct || 0;\n      if(bestCov < 70){\n        hintTxt += ' \u2014 split field for full coverage';\n      }\n      recoHintEl.textContent = hintTxt;\n    }\n    \/\/ Refresh Compare All cells with swept metrics using paintCmpRow so\n    \/\/ the 10-column layout stays consistent. Previously this block wrote\n    \/\/ to a 4-cell layout (cov\/overlap\/fuel\/cost) that no longer exists\n    \/\/ \u2014 fuel was landing in the Area-at-risk column and cost in Repeats\n    \/\/ (\"Soil compaction in liters??\" \u2014 reported 2026-06-03).\n    for(var aa=0; aa<approaches.length; aa++){\n      var apX = approaches[aa];\n      if(apMetrics[apX]) paintCmpRow(apX, apMetrics[apX]);\n    }\n    \/\/ The axis sweep can flip the best approach (e.g. AB-curve at a\n    \/\/ refined angle beats the PCA-default approach). Update lastBestPick\n    \/\/ + auto-swap the radio so the page shows the actually-best plan\n    \/\/ 600 ms after the user changed field \/ width \/ headland \u2014 without\n    \/\/ this hook the BEST badge moves to the swept winner but the radio\n    \/\/ sticks to the synchronous-recompute pick.\n    lastBestPick = recoMetric.pick;\n    autoPickBestApproach();\n  }\n  \/\/ Compute the 3 optimization-target angles (longest edge, fewest\n  \/\/ crossings, shortest distance) for the CURRENT approach. Coarse\n  \/\/ sweep at 15\u00b0 increments \u2014 enough granularity for the picker's\n  \/\/ visible angle, cheap enough to run on every recompute. Results\n  \/\/ cache to `headingAngles` and feed the picker UI.\n  function computeHeadingStrategies(inp){\n    \/\/ Longest edge \u2014 already the geometric default.\n    var pca = fieldAxis(BOUNDARY);\n    var longestDeg = Math.round(Math.atan2(pca.uy, pca.ux) * 180 \/ Math.PI);\n    while(longestDeg < 0) longestDeg += 180;\n    while(longestDeg >= 180) longestDeg -= 180;\n    headingAngles.longest = longestDeg;\n    \/\/ Skip the cross\/dist sweep when the user has pinned Custom \u2014 the\n    \/\/ sweep is purely informational at that point and the user doesn't\n    \/\/ want recompute cost on every slider tick. Also skip for Boundary\n    \/\/ (spiral, no heading) and Topography (algorithm-set heading) \u2014 the\n    \/\/ picker is hidden in those modes so the swept angles aren't shown.\n    if(headingStrategy === 'custom' ? true : current === 'boundary' ? true : current === 'adaptive'){\n      updateHeadingPickerUI();\n      return;\n    }\n    \/\/ Working + turn speed for the \"shortest time\" strategy. Turn-arounds\n    \/\/ are driven slower than the working pass (engine + hydraulics under a\n    \/\/ tight pivot) \u2014 same 0.6\u00d7 factor the analytics time breakdown uses.\n    var speedKmhH = parseFloat((document.getElementById('gpl-speed') || {}).value) || 10;\n    var speedMs = unitSystem === 'us' ? speedKmhH \/ KM_TO_MI : speedKmhH;\n    var workMpsH = speedMs * 1000 \/ 3600;\n    var turnMpsH = workMpsH * 0.6;\n    \/\/ Heavy fields (large multi-part imports like the 207 ha \/ 9-part\n    \/\/ 41.22\u2026 field) make each generateLinesAll cost ~0.6-1.6 s \u2014 a 12-angle\n    \/\/ synchronous sweep then freezes the UI for 10-20 s. Drop to a coarse\n    \/\/ 4-angle sweep when the field is big (span\/wM > 60 or > 100 verts) so\n    \/\/ the picker still shows sensible optimised angles without the freeze.\n    var hgTotalVerts = 0, hgSpan = 0;\n    for(var hgp=0; hgp<BOUNDARY_PARTS.length; hgp++){\n      hgTotalVerts += BOUNDARY_PARTS[hgp].length;\n      var hmnx=Infinity,hmxx=-Infinity,hmny=Infinity,hmxy=-Infinity;\n      for(var hgv=0; hgv<BOUNDARY_PARTS[hgp].length; hgv++){\n        var hpt = BOUNDARY_PARTS[hgp][hgv];\n        if(hpt.x<hmnx)hmnx=hpt.x; if(hpt.x>hmxx)hmxx=hpt.x;\n        if(hpt.y<hmny)hmny=hpt.y; if(hpt.y>hmxy)hmxy=hpt.y;\n      }\n      var hsp = Math.max(hmxx-hmnx, hmxy-hmny);\n      if(hsp > hgSpan) hgSpan = hsp;\n    }\n    var hgHeavy = (inp.wM > 0 ? hgSpan \/ inp.wM > 60 : false) ? true : hgTotalVerts > 100;\n    var sweepDegs = hgHeavy\n      ? [0, 45, 90, 135]\n      : [0, 15, 30, 45, 60, 75, 90, 105, 120, 135, 150, 165];\n    if(sweepDegs.indexOf(longestDeg) < 0) sweepDegs.push(longestDeg);\n    var bestCrossDeg = longestDeg, bestCross = Infinity;\n    var bestDistDeg = longestDeg, bestDist = Infinity;\n    var bestTimeDeg = longestDeg, bestTime = Infinity;\n    for(var s=0; s<sweepDegs.length; s++){\n      var r = sweepDegs[s] * Math.PI \/ 180;\n      var trialAxis = { ux: Math.cos(r), uy: Math.sin(r) };\n      var trialLay = generateLinesAll(current, inp.wM, BOUNDARY_PARTS, trialAxis, inp.headlandM, inp.turnStyle, inp.turnR, inp.turnBuf);\n      var trialMet = computeMetrics(trialLay, inp.wM, inp.diesel, inp.cons);\n      \/\/ Reject candidates that lose meaningful coverage \u2014 minimising\n      \/\/ crossings or distance is pointless if it leaves half the field\n      \/\/ unworked. 5 % cov drop vs Longest Edge is the cap.\n      var trialCov = trialMet.coveragePct || 0;\n      if(trialCov < 70) continue;\n      var crossN = trialMet.crossings || 0;\n      var distM = trialMet.totalDriveM || 0;\n      \/\/ Time = work-time (pass length \/ work speed) + turn-time\n      \/\/ ((turn + transport length) \/ turn speed). Differs from distance\n      \/\/ because turn metres cost more wall-clock than working metres, so\n      \/\/ the time-optimal heading can trade a bit of extra distance for\n      \/\/ fewer\/shorter turnarounds.\n      var timeS = 0;\n      if(workMpsH > 0){\n        timeS = (trialMet.passLengthM || 0) \/ workMpsH\n              + ((trialMet.turnLengthM || 0) + (trialMet.transportLengthM || 0)) \/ (turnMpsH > 0 ? turnMpsH : workMpsH);\n      }\n      if(crossN < bestCross){ bestCross = crossN; bestCrossDeg = sweepDegs[s]; }\n      if(distM > 0 ? distM < bestDist : false){ bestDist = distM; bestDistDeg = sweepDegs[s]; }\n      if(timeS > 0 ? timeS < bestTime : false){ bestTime = timeS; bestTimeDeg = sweepDegs[s]; }\n    }\n    headingAngles.crossings = bestCrossDeg;\n    headingAngles.distance = bestDistDeg;\n    headingAngles.time = bestTimeDeg;\n    updateHeadingPickerUI();\n  }\n  \/\/ Show the picker block that matches the current approach. AB Straight\n  \/\/ and AB Curve use the full radio picker; Boundary Follow shows a \"not\n  \/\/ used\" message; Topography Follow shows the algorithm-set heading\n  \/\/ (perpendicular to mean gradient) read-only.\n  function updateHeadingCardMode(){\n    var na = document.getElementById('gpl-heading-na');\n    var auto = document.getElementById('gpl-heading-auto');\n    var picker = document.getElementById('gpl-heading-picker');\n    var blocksList = document.getElementById('gpl-blocks-list');\n    if(!na ? true : !auto ? true : !picker) return;\n    if(current === 'auto-blocks'){\n      \/\/ Show the per-block list. Rows get populated by renderBlocksList\n      \/\/ AFTER recompute, so the heading-card transitions stay snappy\n      \/\/ (decomposition runs inside generateLinesAll, which has already\n      \/\/ produced layout.blocks by the time recompute calls us).\n      na.hidden = true; auto.hidden = true; picker.hidden = true;\n      if(blocksList) blocksList.hidden = false;\n    } else if(current === 'boundary'){\n      na.hidden = false; auto.hidden = true; picker.hidden = true;\n      if(blocksList) blocksList.hidden = true;\n      na.innerHTML = \"<strong>Not used.<\/strong> Boundary Follow spirals from the field edge inward \u2014 there's no AB heading to set.\";\n    } else if(current === 'adaptive'){\n      na.hidden = true; auto.hidden = false; picker.hidden = true;\n      if(blocksList) blocksList.hidden = true;\n      var deg = computeTopographyHeading();\n      var degEl = document.getElementById('gpl-heading-auto-deg');\n      if(degEl) degEl.textContent = (deg !== null ? deg.toFixed(0) : '\u2014') + '\u00b0';\n    } else {\n      na.hidden = true; auto.hidden = true; picker.hidden = false;\n      if(blocksList) blocksList.hidden = true;\n    }\n  }\n  \/\/ Re-render the per-block list with the latest decomposition. Called\n  \/\/ by recompute() after generateLinesAll returns its block descriptors.\n  \/\/ Each row: \"Block N \u00b7 X.X ha   [\u00b0input]   \u21bareset-one\"\n  function renderBlocksList(blocks){\n    var rowsEl = document.getElementById('gpl-blocks-rows');\n    if(!rowsEl) return;\n    rowsEl.innerHTML = '';\n    if(!blocks ? true : blocks.length === 0){\n      rowsEl.innerHTML = '<div class=\"gpl-hint\">No blocks detected \u2014 field is a single convex shape.<\/div>';\n      return;\n    }\n    for(var i=0; i<blocks.length; i++){\n      var bk = blocks[i];\n      var row = document.createElement('div');\n      row.className = 'gpl-blocks-row' + (bk.isOverride ? ' is-override' : '');\n      row.setAttribute('data-block-key', bk.key);\n      row.innerHTML =\n        '<span class=\"gpl-blocks-lbl\">Block ' + (i + 1) + '<\/span>' +\n        '<span class=\"gpl-blocks-area\">' + bk.areaHa.toFixed(1) + ' ha<\/span>' +\n        '<input type=\"number\" min=\"0\" max=\"179\" step=\"1\" value=\"' + Math.round(bk.axisDeg) + '\" data-block-input>' +\n        '<span class=\"gpl-blocks-deg-unit\">\u00b0<\/span>' +\n        '<button type=\"button\" class=\"gpl-blocks-reset-one\" title=\"Reset to algorithmic axis\" data-block-reset' + (bk.isOverride ? '' : ' disabled') + '>\u21ba<\/button>';\n      rowsEl.appendChild(row);\n    }\n    \/\/ Wire change + reset events\n    var inputs = rowsEl.querySelectorAll('input[data-block-input]');\n    for(var ii=0; ii<inputs.length; ii++){\n      inputs[ii].addEventListener('change', function(){\n        var rowEl = this.closest('.gpl-blocks-row');\n        var key = rowEl.getAttribute('data-block-key');\n        var v = parseFloat(this.value);\n        if(isNaN(v)) return;\n        while(v < 0) v += 180;\n        while(v >= 180) v -= 180;\n        BLOCK_AXIS_OVERRIDES[key] = v;\n        recompute();\n      });\n    }\n    var resets = rowsEl.querySelectorAll('button[data-block-reset]');\n    for(var ri=0; ri<resets.length; ri++){\n      resets[ri].addEventListener('click', function(){\n        var rowEl = this.closest('.gpl-blocks-row');\n        var key = rowEl.getAttribute('data-block-key');\n        delete BLOCK_AXIS_OVERRIDES[key];\n        recompute();\n      });\n    }\n  }\n  \/\/ Mirror the gradient-perpendicular AB-Straight rotation that\n  \/\/ generateLines() does for 'adaptive'. Returns the heading angle in\n  \/\/ degrees (0..179) the user will actually see drawn.\n  function computeTopographyHeading(){\n    var pca = fieldAxis(BOUNDARY);\n    var ux = pca.ux, uy = pca.uy;\n    var stats = fieldStats(BOUNDARY);\n    var gxSum = 0, gySum = 0, gMagSum = 0;\n    var GRID = 5;\n    for(var gxi=0; gxi<GRID; gxi++){\n      for(var gyi=0; gyi<GRID; gyi++){\n        var sx = stats.minX + (gxi + 0.5) * (stats.maxX - stats.minX) \/ GRID;\n        var sy = stats.minY + (gyi + 0.5) * (stats.maxY - stats.minY) \/ GRID;\n        if(!pointInPoly(sx, sy, BOUNDARY)) continue;\n        var gxS = (terrainAt(sx + 1, sy) - terrainAt(sx - 1, sy)) * 0.5;\n        var gyS = (terrainAt(sx, sy + 1) - terrainAt(sx, sy - 1)) * 0.5;\n        var gMagS = Math.sqrt(gxS * gxS + gyS * gyS);\n        gxSum += gxS * gMagS;\n        gySum += gyS * gMagS;\n        gMagSum += gMagS;\n      }\n    }\n    var gAvgMag = gMagSum > 0 ? Math.sqrt(gxSum * gxSum + gySum * gySum) \/ gMagSum : 0;\n    if(gAvgMag > 0.005){\n      var gN = Math.sqrt(gxSum * gxSum + gySum * gySum);\n      var gNx = gxSum \/ gN, gNy = gySum \/ gN;\n      ux = -gNy; uy = gNx;\n    }\n    var deg = Math.round(Math.atan2(uy, ux) * 180 \/ Math.PI);\n    while(deg < 0) deg += 180;\n    while(deg >= 180) deg -= 180;\n    return deg;\n  }\n  function updateHeadingPickerUI(){\n    updateHeadingCardMode();\n    var L = document.getElementById('gpl-h-longest');\n    var C = document.getElementById('gpl-h-crossings');\n    var D = document.getElementById('gpl-h-distance');\n    var T = document.getElementById('gpl-h-time');\n    var Cu = document.getElementById('gpl-h-custom');\n    if(L) L.textContent = headingAngles.longest + '\u00b0';\n    if(C) C.textContent = headingAngles.crossings !== null ? headingAngles.crossings + '\u00b0' : '\u2014';\n    if(D) D.textContent = headingAngles.distance !== null ? headingAngles.distance + '\u00b0' : '\u2014';\n    if(T) T.textContent = headingAngles.time !== null ? headingAngles.time + '\u00b0' : '\u2014';\n    if(Cu) Cu.textContent = (headingStrategy === 'custom' ? (userAxisDeg !== null ? userAxisDeg.toFixed(0) + '\u00b0' : 'slider') : 'slider');\n    \/\/ Mark \"\u2261\" suffix on strategies whose angle matches another \u2014 useful\n    \/\/ visual cue that the optimisers converged to the same heading,\n    \/\/ so picking one over another is moot.\n    var angles = { longest: headingAngles.longest, crossings: headingAngles.crossings, distance: headingAngles.distance, time: headingAngles.time };\n    var keys = ['longest', 'crossings', 'distance', 'time'];\n    for(var ki=0; ki<keys.length; ki++){\n      var k = keys[ki], same = false;\n      for(var kj=0; kj<keys.length; kj++){\n        if(kj === ki) continue;\n        if(angles[k] !== null ? angles[keys[kj]] === angles[k] : false){ same = true; break; }\n      }\n      var optEl = document.querySelector('.gpl-heading-opt[data-strategy=\"' + k + '\"]');\n      if(optEl) optEl.classList.toggle('is-converge', same);\n    }\n    var opts = document.querySelectorAll('.gpl-heading-opt');\n    for(var i=0; i<opts.length; i++){\n      var optStrat = opts[i].getAttribute('data-strategy');\n      var on = optStrat === headingStrategy;\n      opts[i].classList.toggle('is-on', on);\n      var radio = opts[i].querySelector('input[type=\"radio\"]');\n      if(radio) radio.checked = on;\n    }\n    var picker = document.querySelector('.gpl-heading-picker');\n    if(picker) picker.setAttribute('data-mode', headingStrategy);\n    \/\/ Update the slider's label too\n    var valHint = document.getElementById('gpl-ab-val');\n    if(valHint){\n      var d = currentHeadingDeg();\n      valHint.textContent = (d !== null ? d.toFixed(0) : '0') + '\u00b0';\n    }\n  }\n  function currentHeadingDeg(){\n    if(headingStrategy === 'custom') return userAxisDeg;\n    return headingAngles[headingStrategy];\n  }\n  function applyHeadingStrategy(strategy){\n    headingStrategy = strategy;\n    if(strategy === 'custom'){\n      \/\/ Keep userAxisDeg as-is (slider value); if it was null, seed it\n      \/\/ with Longest Edge so the slider has a starting point.\n      if(userAxisDeg === null) userAxisDeg = headingAngles.longest;\n    } else {\n      var ang = headingAngles[strategy];\n      userAxisDeg = ang !== null ? ang : null;\n    }\n    updateHeadingPickerUI();\n    recompute();\n  }\n  function recompute(){\n    var inp = getInputs();\n    \/\/ Compute strategy angles ONCE per recompute, then resolve userAxisDeg\n    \/\/ from the current strategy. 'longest' resets userAxisDeg to null so\n    \/\/ downstream code falls back to fieldAxis() like before.\n    if(headingStrategy !== 'custom'){\n      \/\/ Will be resolved from headingAngles below.\n    }\n    \/\/ Axis: user manual override beats PCA when set\n    var axis;\n    if(userAxisDeg !== null){\n      var r = userAxisDeg * Math.PI \/ 180;\n      axis = { ux: Math.cos(r), uy: Math.sin(r) };\n    } else {\n      axis = fieldAxis(BOUNDARY);\n    }\n    \/\/ Sync the AB-direction slider label to the active angle (so the user\n    \/\/ sees the auto-PCA result reflected in the UI).\n    var abLabelEl = document.getElementById('gpl-ab-val');\n    var abSliderEl = document.getElementById('gpl-ab-deg');\n    if(abLabelEl ? abSliderEl : false){\n      var degNow = Math.round(Math.atan2(axis.uy, axis.ux) * 180 \/ Math.PI);\n      \/\/ Wrap into [0,180) \u2014 the axis is bidirectional (a line, not a vector).\n      while(degNow < 0) degNow += 180;\n      while(degNow >= 180) degNow -= 180;\n      if(userAxisDeg === null) abSliderEl.value = String(degNow);\n      abLabelEl.textContent = (userAxisDeg === null ? 'auto \u00b7 ' : 'manual \u00b7 ') + degNow + '\u00b0';\n    }\n    var stats = fieldStatsAll(BOUNDARY_PARTS);\n    var areaHa = stats.area \/ 10000;\n    var baseLayout = generateLinesAll('ab-straight', inp.wM, BOUNDARY_PARTS, axis, inp.headlandM, inp.turnStyle, inp.turnR, inp.turnBuf);\n    var baseMet = computeMetrics(baseLayout, inp.wM, inp.diesel, inp.cons);\n    \/\/ 'uploaded' is a special approach \u2014 its layout is synthesised from\n    \/\/ the imported guidance lines rather than generated. Treat it as\n    \/\/ a first-class approach: same plumbing for analytics, playback,\n    \/\/ and Compare All highlighting.\n    var hasUploadForView = UPLOADED_LINES ? UPLOADED_LINES.length > 0 : false;\n    var layout;\n    if(current === 'uploaded' ? hasUploadForView : false){\n      layout = buildUploadedLayout();\n    }\n    if(!layout) layout = generateLinesAll(current === 'uploaded' ? 'ab-straight' : current, inp.wM, BOUNDARY_PARTS, axis, inp.headlandM, inp.turnStyle, inp.turnR, inp.turnBuf);\n    var met = computeMetrics(layout, inp.wM, inp.diesel, inp.cons);\n    \/\/ Auto-blocks \u2014 populate the per-block heading list from the\n    \/\/ descriptors the planner attached to the layout. Called here\n    \/\/ (after layout) so the UI rows always reflect the LATEST\n    \/\/ decomposition (which can change with wM, simplification, or\n    \/\/ input field switch).\n    if(current === 'auto-blocks') renderBlocksList(layout.blocks || []);\n    document.getElementById('gpl-r-area').textContent = fmtArea(areaHa);\n    var covEl = document.getElementById('gpl-r-cov');\n    if(covEl){\n      var covVal = met.coveragePct || 0;\n      covEl.textContent = covVal.toFixed(0) + ' %';\n      \/\/ Color: green \u2265 95%, orange 85-95%, red < 85%\n      covEl.style.color = covVal >= 95 ? '#15701e' : (covVal >= 85 ? '#f76a0c' : '#dc2626');\n    }\n    \/\/ Overlap area in hectares (rebuilt UI dropped the % row in favour\n    \/\/ of absolute area, matching the field-summary panel format Ag\n    \/\/ operators expect). Green at < 2 % of field, orange 2-10 %, red > 10 %.\n    var ovHaEl = document.getElementById('gpl-r-overlap-ha');\n    if(ovHaEl){\n      var ovHa = (met.overlapM2 || 0) \/ 10000;\n      var ovFrac = met.fieldM2 > 0 ? (met.overlapM2 \/ met.fieldM2) : 0;\n      ovHaEl.textContent = fmtArea(ovHa);\n      ovHaEl.style.color = ovFrac <= 0.02 ? '#15701e' : (ovFrac <= 0.10 ? '#f76a0c' : '#dc2626');\n    }\n    \/\/ Missed area \u2014 field area NOT touched by any swath. Green < 5 %,\n    \/\/ orange 5\u201315 %, red > 15 %.\n    var msEl = document.getElementById('gpl-r-missed');\n    if(msEl){\n      \/\/ Derive missed area from coverage % (raster-based) \u00d7 fieldM2\n      \/\/ (polygon area). Subtracting raster coveredM2 from polygon\n      \/\/ fieldM2 mixes two grids and produces a 3\u20138 % phantom miss even\n      \/\/ at 95 %+ coverage (raster boundary cells systematically\n      \/\/ under-count vs polygon area).\n      var covPct = met.coveragePct || 0;\n      var missedFrac = Math.max(0, 1 - covPct \/ 100);\n      var missedHa = ((met.fieldM2 || 0) * missedFrac) \/ 10000;\n      msEl.textContent = fmtArea(missedHa);\n      msEl.style.color = missedFrac <= 0.05 ? '#15701e' : (missedFrac <= 0.15 ? '#f76a0c' : '#dc2626');\n    }\n    \/\/ Productivity (ha\/h) + working\/non-working time breakdown. Working\n    \/\/ distance = pass length (implement engaged). Non-working = turns +\n    \/\/ transports. Speeds: configurable working speed (default 10 km\/h),\n    \/\/ non-working at 1.5\u00d7 working (industry rule for headland transit\n    \/\/ with implement lifted). Productivity = field area \/ working time.\n    var speedKmh = parseFloat((document.getElementById('gpl-speed') || {}).value) || 10;\n    if(speedKmh < 2) speedKmh = 2;\n    if(unitSystem === 'us') speedKmh = speedKmh * 1.609344;  \/\/ input was mph\n    \/\/ Working speed \u2248 what the operator does on body passes. The\n    \/\/ previous code multiplied this by 1.5\u00d7 for non-working time\n    \/\/ (= turnarounds + multi-part transports) which is backwards \u2014\n    \/\/ real operators SLOW DOWN for U-turns at the headland (5-8 km\/h\n    \/\/ typical), not speed up. Turn time is now passLength-implied\n    \/\/ working speed \u00d7 0.6 (\u2248 6 km\/h when working speed is 10 km\/h),\n    \/\/ matching industry-published headland-turn speeds.\n    var workMps = speedKmh * 1000 \/ 3600;\n    var turnMps = workMps * 0.6;  \/\/ headland turn-arounds: slower\n    var workSec = workMps > 0 ? (met.passLengthM || 0) \/ workMps : 0;\n    var transSec = turnMps > 0 ? ((met.turnLengthM || 0) + (met.transportLengthM || 0)) \/ turnMps : 0;\n    var totalSec = workSec + transSec;\n    function fmtTime(sec){\n      if(sec <= 0) return '\u2014 h \u2014';\n      var h = Math.floor(sec \/ 3600);\n      var m = Math.round((sec % 3600) \/ 60);\n      if(m === 60){ h += 1; m = 0; }\n      return h + ' h ' + (m < 10 ? '0' : '') + m + ' min';\n    }\n    var ttotEl = document.getElementById('gpl-r-time-total');\n    var twkEl  = document.getElementById('gpl-r-time-work');\n    var tntEl  = document.getElementById('gpl-r-time-trans');\n    if(ttotEl) ttotEl.textContent = fmtTime(totalSec);\n    if(twkEl)  twkEl.textContent  = fmtTime(workSec);\n    if(tntEl)  tntEl.textContent  = fmtTime(transSec);\n    var prodEl = document.getElementById('gpl-r-prod');\n    if(prodEl){\n      var prod = workSec > 0 ? (areaHa \/ (workSec \/ 3600)) : 0;\n      if(unitSystem === 'us'){\n        prodEl.textContent = (prod * HA_TO_AC).toFixed(1) + ' ac\/h';\n      } else {\n        prodEl.textContent = prod.toFixed(2) + ' ha\/h';\n      }\n    }\n    \/\/ Path-crossings count. Green \u2264 4, orange 5\u20138, red > 8 (rule \u00a76\n    \/\/ threshold above which recommendByMetrics refuses to flag \"Best\").\n    var crEl = document.getElementById('gpl-r-crossings');\n    if(crEl){\n      var crVal = met.crossings || 0;\n      crEl.textContent = String(crVal);\n      crEl.style.color = crVal <= 4 ? '#15701e' : (crVal <= 8 ? '#f76a0c' : '#dc2626');\n    }\n    document.getElementById('gpl-r-passes').textContent = met.passes;\n    document.getElementById('gpl-r-len').textContent = fmtDist(met.passLengthM);\n    document.getElementById('gpl-r-turns').textContent = met.turns;\n    var turnLenEl = document.getElementById('gpl-r-turnlen');\n    if(turnLenEl) turnLenEl.textContent = fmtDist(met.turnLengthM || 0);\n    var driveEl = document.getElementById('gpl-r-drive');\n    if(driveEl) driveEl.textContent = fmtDist(met.totalDriveM || (met.passLengthM + (met.turnLengthM || 0)));\n    document.getElementById('gpl-r-fuel').textContent = fmtVolume(met.fuelL);\n    var gradeEl = document.getElementById('gpl-r-grade');\n    if(gradeEl){\n      var g = met.avgGradePct || 0;\n      gradeEl.textContent = g.toFixed(1) + ' %';\n      gradeEl.style.color = g > 5 ? '#f76a0c' : (g > 3 ? '#a21caf' : '#4c6066');\n    }\n    var slopeEl = document.getElementById('gpl-r-slope');\n    if(slopeEl){\n      var slopePct = (met.slopePenalty || 0) * 100;\n      slopeEl.textContent = '+ ' + slopePct.toFixed(0) + '%';\n      slopeEl.style.color = slopePct > 15 ? '#dc2626' : (slopePct > 5 ? '#f76a0c' : '#4c6066');\n    }\n    var turnFuelEl = document.getElementById('gpl-r-turnfuel');\n    if(turnFuelEl){\n      var tf = met.turnFuelL || 0;\n      var totF = met.fuelL || 0;\n      var pct = totF > 0 ? (tf \/ totF * 100) : 0;\n      turnFuelEl.textContent = fmtVolume(tf) + ' \u00b7 ' + pct.toFixed(0) + '%';\n      turnFuelEl.style.color = pct > 25 ? '#f76a0c' : (pct > 15 ? '#a21caf' : '#4c6066');\n    }\n    var sym = CURRENCY_SYMBOL[inp.currency] || '$';\n    document.getElementById('gpl-r-cost').textContent = sym + ' ' + met.costUSD.toFixed(2);\n    \/\/ CO\u2082 from diesel burned. 2.68 kg CO\u2082 \/ litre is the standard\n    \/\/ agricultural-diesel emission factor (DEFRA \/ EPA midpoint). Surfaced\n    \/\/ because operators increasingly report Scope-1 farm emissions, and\n    \/\/ the competitor benchmark deck called it out on every field.\n    var co2El = document.getElementById('gpl-r-co2');\n    if(co2El){\n      var co2kg = (met.fuelL || 0) * 2.68;\n      co2El.textContent = co2kg.toFixed(1) + ' kg';\n    }\n    var savFuel = baseMet.fuelL - met.fuelL;\n    var savPct = baseMet.fuelL > 0 ? (savFuel \/ baseMet.fuelL) * 100 : 0;\n    var savEl = document.getElementById('gpl-r-sav');\n    if(current === 'ab-straight'){\n      savEl.textContent = 'baseline';\n      savEl.style.color = '#4c6066';\n    } else {\n      var sign = savFuel >= 0 ? '\u2212' : '+';\n      var absSav = Math.abs(savFuel);\n      savEl.textContent = sign + fmtVolume(absSav) + ' (' + (savPct >= 0 ? '\u2212' : '+') + Math.abs(savPct).toFixed(0) + '%)';\n      savEl.style.color = savFuel >= 0 ? '#15701e' : '#f76a0c';\n    }\n    trayEl.textContent = fmtArea(areaHa) + ' \u00b7 ' + fmtWidth(inp.wM) + ' \u00b7 ' + met.passes + ' passes \u00b7 headland ' + fmtWidth(inp.headlandM);\n    \/\/ BEST badge is driven by recommendByMetrics \u2014 fired in the deferred\n    \/\/ setTimeout below AFTER all per-approach metrics are computed AND\n    \/\/ re-fired by the axis-sweep callback. Both update the SAME source\n    \/\/ (recommendByMetrics) so the left-panel badge stays in sync with\n    \/\/ the bottom \"Best for this field\" panel (reported 2026-06-03 \u2014 the\n    \/\/ old shape-driven sync reco here was overwriting the metric pick\n    \/\/ with a different shape-based pick, leaving the two views\n    \/\/ disagreeing for the entire recompute cycle).\n    \/\/ Build the per-approach metrics map up FRONT so downstream code (ROI\n    \/\/ block, Compare All table, warning banner, deferred axis sweep) can all\n    \/\/ read from one source. Previously this lived AFTER the ROI block which\n    \/\/ crashed when ROI tried to look up apMetrics['ab-curve'] before the map\n    \/\/ had been populated.\n    \/\/ Imported fields don't have real elevation data \u2192 Topography\n    \/\/ follow degenerates to AB Straight. Skip it from Compare All to\n    \/\/ (a) avoid showing duplicate metrics and (b) save the ~70 ms\n    \/\/ computeMetrics + per-angle sweep cost on heavy fields.\n    var isImportedField = currentField === 'custom' ? true : currentField === 'uploaded-lines';\n    var hasUploadedLines = UPLOADED_LINES ? UPLOADED_LINES.length > 0 : false;\n    var approaches = isImportedField\n      ? ['ab-straight', 'ab-curve', 'boundary', 'auto-blocks']\n      : ['ab-straight', 'ab-curve', 'boundary', 'adaptive', 'auto-blocks'];\n    if(hasUploadedLines) approaches.push('uploaded');\n    \/\/ Hide the adaptive row from the Compare All table for imports.\n    var cmpAdaptiveRow = document.querySelector('.gpl-cmp-row[data-cmp=\"adaptive\"]');\n    if(cmpAdaptiveRow) cmpAdaptiveRow.hidden = isImportedField;\n    \/\/ Seed apMetrics with the current approach (already computed) so the\n    \/\/ ROI block + recoMetric below have at least one entry to read.\n    \/\/ OTHER approaches' Compare All cells fill in via deferred\n    \/\/ computation below \u2014 this lets the canvas paint first (~70 ms after\n    \/\/ import) before the heavier ~3 \u00d7 70 ms compare cycle blocks the UI.\n    var apMetrics = {};\n    apMetrics[current] = met;\n    \/\/ Render the current row immediately with \"\u2014\" placeholders in\n    \/\/ other rows (existing default state). The deferred loop populates\n    \/\/ the rest after the first paint.\n    \/\/ Paint a single Compare All row. 10 numeric columns:\n    \/\/   Coverage \/ Missed area \/ Compaction \/ Repeats \/ Path crossings\n    \/\/   \/ Distance \/ Turnarounds \/ Time \/ Fuel \/ Cost\n    \/\/ All units respect the unitSystem (metric vs US) and currency\n    \/\/ (sym) sourced from the Analytics inputs. Raw numbers stored\n    \/\/ in dataset.* attrs so the column sort can compare them directly.\n    function paintCmpRow(ap, mObj){\n      var rowEl = document.querySelector('.gpl-cmp-row[data-cmp=\"' + ap + '\"]');\n      if(!rowEl) return;\n      var vals = rowEl.querySelectorAll('.gpl-cmp-val');\n      var cov = mObj.coveragePct || 0;\n      var fieldM2 = mObj.fieldM2 || 0;\n      var overlapM2 = mObj.overlapM2 || 0;\n      \/\/ Missed area MUST be derived from coverage % \u00d7 fieldM2, NOT from\n      \/\/ (fieldM2 \u2212 coveredM2). The two source figures are computed\n      \/\/ differently (fieldM2 = polygon area; coveredM2 = raster cell\n      \/\/ count \u00d7 cellArea \u2014 raster discretization under-counts boundary\n      \/\/ cells), and subtracting them produces a spurious \"missed\" of\n      \/\/ 3\u20138 % even at 95 %+ coverage. Deriving from the percentage keeps\n      \/\/ the two columns mathematically consistent on every row.\n      var missedFrac = Math.max(0, 1 - cov \/ 100);\n      var missedM2 = missedFrac * fieldM2;\n      var missedHa = missedM2 \/ 10000;\n      var compactionHa = overlapM2 \/ 10000;\n      var repeats = mObj.overlapPct || 0;\n      var cross = mObj.crossings || 0;\n      var totalKm = (mObj.totalDriveM || (mObj.passLengthM + (mObj.turnLengthM || 0))) \/ 1000;\n      var passKm = (mObj.passLengthM || 0) \/ 1000;\n      var turnKm = (mObj.turnLengthM || 0) \/ 1000;\n      var turns = mObj.turns || 0;\n      var fuelL = mObj.fuelL || 0;\n      var costUSD = mObj.costUSD || 0;\n      \/\/ Time = working km \/ working speed + non-working km \/ 0.6 \u00d7\n      \/\/ working speed (matches the Analytics panel formula).\n      var speedKmh = parseFloat((document.getElementById('gpl-speed') || {}).value) || 10;\n      if(unitSystem === 'us') speedKmh = speedKmh * 1.609344;\n      if(speedKmh < 2) speedKmh = 2;\n      var workSec = passKm * 1000 \/ (speedKmh * 1000 \/ 3600);\n      var turnSec = turnKm * 1000 \/ (speedKmh * 0.6 * 1000 \/ 3600);\n      var totalSec = workSec + turnSec;\n      var hours = Math.floor(totalSec \/ 3600);\n      var mins = Math.round((totalSec % 3600) \/ 60);\n      if(mins === 60){ hours += 1; mins = 0; }\n      var timeStr = hours + ' h ' + (mins < 10 ? '0' : '') + mins;\n      \/\/ Cell 0: Coverage (%)\n      vals[0].textContent = cov.toFixed(0) + ' %';\n      vals[0].style.color = cov >= 95 ? '#15701e' : (cov >= 85 ? '#f76a0c' : '#dc2626');\n      \/\/ Cell 1: Missed area (ha\/ac)\n      vals[1].textContent = fmtArea(missedHa);\n      var missedFrac = fieldM2 > 0 ? missedM2 \/ fieldM2 : 0;\n      vals[1].style.color = missedFrac <= 0.05 ? '#15701e' : (missedFrac <= 0.15 ? '#f76a0c' : '#dc2626');\n      \/\/ Cell 2: Compaction (ha\/ac of overlapping wheel work)\n      vals[2].textContent = fmtArea(compactionHa);\n      var compactionFrac = fieldM2 > 0 ? overlapM2 \/ fieldM2 : 0;\n      vals[2].style.color = compactionFrac <= 0.02 ? '#15701e' : (compactionFrac <= 0.10 ? '#f76a0c' : '#dc2626');\n      \/\/ Cell 3: Repeats (% overlap)\n      vals[3].textContent = repeats.toFixed(0) + ' %';\n      vals[3].style.color = repeats <= 5 ? '#15701e' : (repeats <= 20 ? '#f76a0c' : '#dc2626');\n      \/\/ Cell 4: Path crossings (count)\n      vals[4].textContent = String(cross);\n      vals[4].style.color = cross <= 4 ? '#15701e' : (cross <= 8 ? '#f76a0c' : '#dc2626');\n      \/\/ Cell 5: Distance (km\/mi)\n      vals[5].textContent = unitSystem === 'us' ? (totalKm * KM_TO_MI).toFixed(1) + ' mi' : totalKm.toFixed(1) + ' km';\n      \/\/ Cell 6: Turnarounds (count)\n      vals[6].textContent = String(turns);\n      \/\/ Cell 7: Time\n      vals[7].textContent = timeStr;\n      \/\/ Cell 8: Fuel (L\/gal)\n      vals[8].textContent = unitSystem === 'us' ? (fuelL * L_TO_GAL).toFixed(0) + ' gal' : fuelL.toFixed(0) + ' L';\n      \/\/ Cell 9: Cost (currency from Analytics)\n      vals[9].textContent = sym + ' ' + costUSD.toFixed(0);\n      \/\/ Raw numbers for sorting\n      rowEl.dataset.cov = String(cov);\n      rowEl.dataset.missed = String(missedHa);\n      rowEl.dataset.compaction = String(compactionHa);\n      rowEl.dataset.repeats = String(repeats);\n      rowEl.dataset.cross = String(cross);\n      rowEl.dataset.km = String(totalKm);\n      rowEl.dataset.turns = String(turns);\n      rowEl.dataset.time = String(totalSec);\n      rowEl.dataset.fuel = String(fuelL);\n      rowEl.dataset.cost = String(costUSD);\n    }\n    (function paintCurrentRow(){\n      paintCmpRow(current, met);\n      for(var rci=0; rci<approaches.length; rci++){\n        var otherRow = document.querySelector('.gpl-cmp-row[data-cmp=\"' + approaches[rci] + '\"]');\n        if(otherRow) otherRow.classList.toggle('gpl-cmp-current', approaches[rci] === current);\n      }\n    })();\n    \/\/ Defer the heavier other-approach cells (each is a full\n    \/\/ generateLinesAll + computeMetrics). They render asynchronously\n    \/\/ after the canvas has painted the current approach \u2014 UI feels\n    \/\/ instant on big fields. The schedule cancels itself on next\n    \/\/ recompute so input-drag inputs don't queue up stale work.\n    if(compareTimer) clearTimeout(compareTimer);\n    compareTimer = setTimeout(function(){\n      compareTimer = null;\n      for(var a=0; a<approaches.length; a++){\n        var ap = approaches[a];\n        if(ap === current) continue;\n        var apLayout;\n        try {\n          if(ap === 'uploaded'){\n            apLayout = buildUploadedLayout();\n            if(!apLayout) continue;\n          } else {\n            apLayout = generateLinesAll(ap, inp.wM, BOUNDARY_PARTS, axis, inp.headlandM, inp.turnStyle, inp.turnR, inp.turnBuf);\n          }\n        } catch(e){\n          \/\/ generateLinesAll can throw on degenerate inputs (irregular\n          \/\/ imported boundaries via concaveHullFromLines, self-intersecting\n          \/\/ offset rings on thin polygons). Reported 2026-06-03: Boundary\n          \/\/ Follow row stayed empty after uploading lv Eichwiese lines.\n          \/\/ Log + skip so the OTHER approaches still render \u2014 silently\n          \/\/ dropping the row is the regression we're guarding against.\n          console.warn('[GPL] ' + ap + ' generation failed on imported boundary: ' + (e ? (e.message ? e.message : e) : 'unknown'));\n          continue;\n        }\n        \/\/ Defensive: a layout with zero passes means the algorithm\n        \/\/ produced nothing for this boundary (often happens when the\n        \/\/ imported polygon is too small or pathological for the chosen\n        \/\/ approach). Skip painting \u2192 the row stays at \"\u2014\" instead of\n        \/\/ showing misleading zeros.\n        if(!apLayout ? true : !apLayout.passes ? true : apLayout.passes.length === 0){\n          console.warn('[GPL] ' + ap + ' produced empty layout on imported boundary');\n          continue;\n        }\n        var apMet = computeMetrics(apLayout, inp.wM, inp.diesel, inp.cons);\n        apMetrics[ap] = apMet;\n        paintCmpRow(ap, apMet);\n      }\n      \/\/ Refine the \"Best\" recommendation now that all metrics are in.\n      var laterReco = recommendByMetrics(apMetrics);\n      var laterBadges = document.querySelectorAll('.gpl-reco');\n      for(var lb=0; lb<laterBadges.length; lb++){\n        var lkey = laterBadges[lb].getAttribute('data-reco');\n        laterBadges[lb].classList.toggle('is-on', lkey === laterReco.pick);\n      }\n      \/\/ Highlight the best row + write the why-this-one description\n      var allRows = document.querySelectorAll('#gpl-cmp-table .gpl-cmp-row');\n      for(var brI=0; brI<allRows.length; brI++) allRows[brI].classList.remove('is-best');\n      var bestRow = document.querySelector('#gpl-cmp-table .gpl-cmp-row[data-cmp=\"' + laterReco.pick + '\"]');\n      if(bestRow) bestRow.classList.add('is-best');\n      var bestNameEl = document.getElementById('gpl-cmp-best-name');\n      var bestWhyEl = document.getElementById('gpl-cmp-best-why');\n      if(bestNameEl) bestNameEl.textContent = AP_LABELS[laterReco.pick] || laterReco.pick;\n      if(bestWhyEl) bestWhyEl.textContent = laterReco.why || '';\n      var lhint = document.getElementById('gpl-reco-hint');\n      if(lhint){\n        var lapLabel = AP_LABELS[laterReco.pick] || 'Best';\n        lhint.textContent = 'Best: ' + lapLabel + ' \u2014 ' + laterReco.why;\n      }\n      \/\/ CRITICAL: this deferred block is where the TRUE best is known \u2014 the\n      \/\/ synchronous recompute only knows about the CURRENT approach because\n      \/\/ computing every approach inline is too slow on big fields.\n      \/\/ Update lastBestPick here + trigger the auto-pick so the radio +\n      \/\/ canvas swap to the actual best within ~0 ms of the deferred fill.\n      \/\/ Without this hook, the earlier autoPickBestApproach call sees\n      \/\/ lastBestPick === current (only one approach in apMetrics) and\n      \/\/ never swaps \u2014 reported 2026-06-04 \"Best is not auto-selected on\n      \/\/ any field\". Reported across hex, rect, lshape, twoblock \u2014 the\n      \/\/ common cause is the deferred metrics pattern.\n      lastBestPick = laterReco.pick;\n      \/\/ Guard: only auto-pick if the document has finished loading. The\n      \/\/ harness's noop DOM can throw during init when the swap triggers\n      \/\/ a recursive recompute \u2192 draw \u2192 before resize() has set canvas\n      \/\/ backing dimensions.\n      if(typeof document !== 'undefined' ? document.readyState !== 'loading' : false){\n        autoPickBestApproach();\n      }\n      \/\/ Re-apply the current sort now that every row has fresh stats.\n      \/\/ Default is cost ascending so the best-value approach lands on\n      \/\/ top automatically; if the user clicked a header earlier, that\n      \/\/ sort key + direction is preserved across recomputes.\n      if(typeof applyCmpSort === 'function') applyCmpSort();\n    }, 0);\n    \/\/ Uploaded existing guidance lines \u2014 compute a parallel set of metrics\n    \/\/ (drive km, fuel, cost, approximate coverage) so the user can compare\n    \/\/ their EXISTING plan against the four generated proposals in Compare\n    \/\/ All. Layout is synthesised on the fly so computeMetrics's existing\n    \/\/ pipeline can run unmodified.\n    var uploadedRow = document.querySelector('.gpl-cmp-row[data-cmp=\"uploaded\"]');\n    var uploadedLegend = document.getElementById('gpl-lg-uploaded');\n    var uploadedRingLegend = document.getElementById('gpl-lg-uploaded-ring');\n    var hasUploaded = UPLOADED_LINES ? UPLOADED_LINES.length > 0 : false;\n    if(uploadedLegend) uploadedLegend.hidden = !hasUploaded;\n    \/\/ Show the headland-trace legend row only when at least one closed\n    \/\/ loop made it through the import (so the user sees what the solid\n    \/\/ blue line means).\n    var hasUploadedRing = false;\n    if(hasUploaded ? typeof isImportedLineLoop === 'function' : false){\n      for(var ulr=0; ulr<UPLOADED_LINES.length; ulr++){\n        if(isImportedLineLoop(UPLOADED_LINES[ulr])){ hasUploadedRing = true; break; }\n      }\n    }\n    if(uploadedRingLegend) uploadedRingLegend.hidden = !hasUploadedRing;\n    \/\/ Hint banner under the Compare All table: hidden when the user\n    \/\/ HAS uploaded lines (the row itself is enough); shown when they\n    \/\/ haven't, prompting them to upload their existing plan so we can\n    \/\/ benchmark the 5 proposed approaches against it head-to-head.\n    var cmpHint = document.getElementById('gpl-cmp-uploaded-hint');\n    if(cmpHint) cmpHint.hidden = hasUploaded;\n    if(uploadedRow){\n      if(hasUploaded){\n        \/\/ Reuse buildUploadedLayout() so Compare All's \"Your current lines\"\n        \/\/ row inherits the same closed-loop filter + synthetic turn arcs as\n        \/\/ the playback view. computeMetrics then reads passes + turnArcs +\n        \/\/ drivable \u2192 identical numbers in both places.\n        var upLayout = buildUploadedLayout();\n        if(upLayout){\n          var upMet = computeMetrics(upLayout, inp.wM, inp.diesel, inp.cons);\n          apMetrics['uploaded'] = upMet;\n          uploadedRow.hidden = false;\n          paintCmpRow('uploaded', upMet);\n        } else {\n          uploadedRow.hidden = true;\n        }\n      } else {\n        uploadedRow.hidden = true;\n      }\n    }\n    \/\/ Annual ROI extrapolation. Per-field fuel savings \u00d7 applications \u00d7 (farm\/field).\n    \/\/ When the user is on the baseline (AB Straight), \"savings vs self\" is 0 by\n    \/\/ definition. To still show a useful number, surface the potential savings\n    \/\/ from switching to the BEST non-baseline approach available on this field.\n    \/\/ That way the ROI panel always carries a real number instead of \"\u2014\".\n    var roiFuelEl = document.getElementById('gpl-roi-fuel');\n    var roiCostEl = document.getElementById('gpl-roi-cost');\n    var roiPerhaEl = document.getElementById('gpl-roi-perha');\n    if(roiFuelEl ? roiCostEl : false){\n      var scale = areaHa > 0 ? (inp.farmHa \/ areaHa) : 0;\n      \/\/ Pick the \"comparison approach\" \u2014 whose fuel we compare to AB Straight:\n      \/\/   - If user is on baseline, find the cheapest non-baseline approach\n      \/\/     (Contour-follow is most common, but boundary\/curve can win on\n      \/\/     specific shapes).\n      \/\/   - Otherwise, use the user's currently-selected approach.\n      var cmpAp = current;\n      var cmpFuel = met.fuelL;\n      var hintLine = null;\n      if(current === 'ab-straight'){\n        var bestAp = null, bestFuel = baseMet.fuelL;\n        var others = ['ab-curve','boundary','adaptive'];\n        for(var oi=0; oi<others.length; oi++){\n          var oap = others[oi];\n          var oMet = apMetrics[oap];\n          if(oMet ? oMet.fuelL < bestFuel : false){\n            bestFuel = oMet.fuelL;\n            bestAp = oap;\n          }\n        }\n        if(bestAp){\n          cmpAp = bestAp;\n          cmpFuel = bestFuel;\n          hintLine = 'vs ' + (AP_LABELS[bestAp] || bestAp);\n        } else {\n          \/\/ No alternative beats baseline on this field.\n          hintLine = 'AB Straight is already optimal here';\n        }\n      }\n      var perFieldFuel = baseMet.fuelL - cmpFuel;\n      var annualFuel = perFieldFuel * inp.apps * scale;\n      var annualCost = annualFuel * inp.diesel;\n      var perAreaLbl = unitSystem === 'us' ? '\/ac' : '\/ha';\n      var farmDispArea = unitSystem === 'us' ? inp.farmHa * HA_TO_AC : inp.farmHa;\n      var perAreaVal = farmDispArea > 0 ? (annualCost \/ farmDispArea) : 0;\n      var muted = '#4c6066', win = '#15701e', loss = '#f76a0c';\n      function setRoi(fuelTxt, costTxt, perTxt, color){\n        roiFuelEl.textContent = fuelTxt;\n        roiCostEl.textContent = costTxt;\n        if(roiPerhaEl) roiPerhaEl.textContent = perTxt;\n        roiFuelEl.style.color = color;\n        roiCostEl.style.color = color;\n        if(roiPerhaEl) roiPerhaEl.style.color = color;\n      }\n      if(Math.abs(perFieldFuel) < 0.05){\n        setRoi(fmtVolume(0), sym + ' 0', hintLine || 'No measurable savings on this field', muted);\n      } else {\n        var signR = annualFuel >= 0 ? '' : '\u2212';\n        var fuelTxt = signR + fmtVolume(Math.abs(annualFuel));\n        var costTxt = (annualCost >= 0 ? sym + ' ' : '\u2212' + sym + ' ') + Math.abs(annualCost).toFixed(0);\n        var perTxt = (perAreaVal >= 0 ? sym + ' ' : '\u2212' + sym + ' ') + Math.abs(perAreaVal).toFixed(2) + perAreaLbl + (hintLine ? ' \u00b7 ' + hintLine : '');\n        setRoi(fuelTxt, costTxt, perTxt, annualFuel >= 0 ? win : loss);\n      }\n    }\n    \/\/ Warning banner from validation cascade. Beyond showing the banner, we\n    \/\/ also pulse the control most likely to fix the warning so the user knows\n    \/\/ WHERE to click. Heuristic on the warning text:\n    \/\/   \"headland\" \/ \"U-turn dips\" \u2192 Headland card\n    \/\/   \"turn radius\" \/ \"turn fits\" \/ \"boundary\" \u2192 Turnarounds card\n    \/\/   \"axis\" \/ \"AB\" \/ \"PCA\" \u2192 AB line direction card\n    \/\/   anything else \u2192 Approach card (the highest-level lever)\n    var warnEl = document.getElementById('gpl-warn');\n    var sideEl = document.querySelector('.gpl-side');\n    function pulseCard(matchH3){\n      if(!sideEl) return;\n      var cards = sideEl.querySelectorAll('.gpl-card');\n      \/\/ Clear previous pulses first so consecutive warnings re-trigger animation.\n      for(var pc=0; pc<cards.length; pc++){\n        cards[pc].classList.remove('gpl-card-attention');\n      }\n      if(!matchH3) return;\n      \/\/ Force a reflow before re-adding the class so the animation actually restarts.\n      void sideEl.offsetWidth;\n      for(var qc=0; qc<cards.length; qc++){\n        var h3 = cards[qc].querySelector('h3');\n        if(h3 ? h3.textContent.toLowerCase().indexOf(matchH3) >= 0 : false){\n          cards[qc].classList.add('gpl-card-attention');\n          \/\/ Pulse animation alone is the attention signal. scrollIntoView\n          \/\/ was here originally, but on every approach click it scrolled\n          \/\/ the WHOLE PAGE (because the matched card sits in the same\n          \/\/ scroll container as the canvas) \u2014 user saw the map \"jump\"\n          \/\/ up\/down as they cycled through approaches with different\n          \/\/ warnings (reported 2026-06-03).\n          break;\n        }\n      }\n    }\n    if(warnEl){\n      if(layout.warning){\n        warnEl.textContent = layout.warning;\n        warnEl.classList.add('is-on');\n        var w = layout.warning.toLowerCase();\n        var target = null;\n        if(w.indexOf('headland') >= 0 ? true : w.indexOf('u-turn dips') >= 0) target = 'headland';\n        else if(w.indexOf('turn radius') >= 0 ? true : (w.indexOf('turn fits') >= 0 ? true : (w.indexOf('no turn') >= 0 ? true : w.indexOf('boundary') >= 0))) target = 'turnarounds';\n        else if(w.indexOf('axis') >= 0 ? true : (w.indexOf('ab line') >= 0 ? true : w.indexOf('pca') >= 0)) target = 'ab line direction';\n        else target = 'approach';\n        pulseCard(target);\n      } else {\n        warnEl.textContent = '';\n        warnEl.classList.remove('is-on');\n        pulseCard(null);\n      }\n    }\n    \/\/ (apMetrics + Compare All cells already populated above, before the ROI\n    \/\/ block \u2014 moved earlier so the ROI logic can read apMetrics without\n    \/\/ tripping the \"Cannot read properties of undefined\" error.)\n    \/\/ Initial recommendation: use the per-approach metrics at the CURRENT\n    \/\/ axis (PCA or user override). The full axis sweep \u2014 which tries 6 extra\n    \/\/ angles per approach for higher-coverage axes \u2014 is deferred to an idle\n    \/\/ window so interactive recomputes stay fast (the sweep is 28 layout\n    \/\/ builds, ~6x more work than the main recompute). scheduleAxisSweep\n    \/\/ below kicks off the sweep 600 ms after the LAST recompute settles.\n    var recoMetric = recommendByMetrics(apMetrics);\n    \/\/ Expose the latest metric-picked best so the field-switch flow + the\n    \/\/ initial-page-load bootstrap can auto-select it after this recompute\n    \/\/ settles. Without this hook the BEST badge highlights the right\n    \/\/ approach but the radio + canvas stay on whatever the user last\n    \/\/ chose, forcing them to click the badge to actually see the plan.\n    lastBestPick = recoMetric.pick;\n    var recoBadges2 = document.querySelectorAll('.gpl-reco');\n    for(var rb2=0; rb2<recoBadges2.length; rb2++){\n      var rkey2 = recoBadges2[rb2].getAttribute('data-reco');\n      recoBadges2[rb2].classList.toggle('is-on', rkey2 === recoMetric.pick);\n    }\n    var recoHintEl2 = document.getElementById('gpl-reco-hint');\n    if(recoHintEl2){\n      var apLabel = AP_LABELS[recoMetric.pick] || 'Best';\n      var hintTxt = 'Best: ' + apLabel + ' \u2014 ' + recoMetric.why;\n      var bestCov = (apMetrics[recoMetric.pick] || {}).coveragePct || 0;\n      if(bestCov < 70){\n        hintTxt += ' \u2014 split field for full coverage';\n      }\n      recoHintEl2.textContent = hintTxt;\n    }\n    setPlaybackPath(layout, inp.wM);\n    updatePlaybackUI();\n    draw(layout);\n    \/\/ Compute heading-strategy angles for the picker (Longest Edge \/ Fewest\n    \/\/ Crossings \/ Shortest Distance). Coarse sweep against the current\n    \/\/ approach \u2014 re-runs on every recompute so the picker stays in sync\n    \/\/ with field + approach + width changes.\n    computeHeadingStrategies(inp);\n    \/\/ Defer the expensive axis sweep \u2014 it refines the Compare All cells +\n    \/\/ recommendation hint by trying alternate AB-line angles. Runs after\n    \/\/ 600 ms of input idle; if another recompute fires first the sweep\n    \/\/ resets.\n    scheduleAxisSweep(inp, axis, approaches, apMetrics, sym);\n  }\n  \/\/ Update the headland slider label. Headland = mult \u00d7 wM (sole source of\n  \/\/ truth \u2014 no separate custom-width input anymore).\n  function updateHeadlandLabel(){\n    \/\/ Implement width input is in the active unit system (m or ft). Convert\n    \/\/ it to metric for the arithmetic, then format the result via fmtWidth\n    \/\/ so the displayed strip width respects the unit system.\n    var rawW = parseFloat(document.getElementById('gpl-wm').value) || 18;\n    var wM = unitSystem === 'us' ? rawW \/ M_TO_FT : rawW;\n    var hlMult = parseFloat(document.getElementById('gpl-hl-mult').value);\n    if(isNaN(hlMult)) hlMult = 1;\n    var labelEl = document.getElementById('gpl-hl-val');\n    if(!labelEl) return;\n    var m = hlMult * wM;\n    labelEl.textContent = fmtWidth(m) + ' \u00b7 ' + hlMult + '\u00d7 pass';\n  }\n  var radios = document.querySelectorAll('#gpl-approach input[type=radio]');\n  for(var ri=0; ri<radios.length; ri++){\n    radios[ri].addEventListener('change', function(){\n      var labs = document.querySelectorAll('#gpl-approach label');\n      for(var li=0; li<labs.length; li++) labs[li].classList.remove('is-on');\n      this.parentNode.classList.add('is-on');\n      current = this.value;\n      \/\/ Explicit user choice \u2014 cancel any pending auto-pick from a recent\n      \/\/ field switch so the deferred Compare All fill doesn't override.\n      pendingAutoPick = false;\n      recompute();\n    });\n  }\n  \/\/ Info-icon tooltips on each approach row. Without these handlers, clicking\n  \/\/ the icon would also click its enclosing label (browser-default label\n  \/\/ behaviour), toggling the radio button. preventDefault + stopPropagation\n  \/\/ on mousedown blocks both the label activation AND the synthetic click\n  \/\/ that fires after. tabindex=0 lets touch users + keyboard users tap or\n  \/\/ Tab to the icon so :focus reveals the tooltip on platforms without\n  \/\/ hover (mobile Safari, Chrome touch).\n  var infoIcons = document.querySelectorAll('#gpl-approach .gpl-info-ico');\n  for(var ic=0; ic<infoIcons.length; ic++){\n    infoIcons[ic].setAttribute('tabindex', '0');\n    infoIcons[ic].setAttribute('role', 'button');\n    var tipTxt = infoIcons[ic].getAttribute('data-tip') || '';\n    infoIcons[ic].setAttribute('aria-label', tipTxt);\n    infoIcons[ic].addEventListener('mousedown', function(ev){\n      ev.preventDefault();\n      ev.stopPropagation();\n    });\n    infoIcons[ic].addEventListener('click', function(ev){\n      ev.preventDefault();\n      ev.stopPropagation();\n      \/\/ On touch devices a click also focuses; re-focus explicitly so :focus\n      \/\/ reveals the tooltip and a second tap elsewhere dismisses it.\n      try { this.focus(); } catch(_){}\n    });\n    infoIcons[ic].addEventListener('keydown', function(ev){\n      \/\/ Block Enter \/ Space from \"clicking\" the label (which would toggle\n      \/\/ the radio). The icon is informational only \u2014 never activates a state.\n      if(ev.key === ' ' ? true : ev.key === 'Enter'){\n        ev.preventDefault();\n        ev.stopPropagation();\n      }\n    });\n  }\n  \/\/ Equipment + economics inputs \u2014 all live updates go through\n  \/\/ scheduleRecompute (debounced), see comment above the helper definition.\n  var basicIds = ['gpl-wm', 'gpl-fuel', 'gpl-cons', 'gpl-speed'];\n  for(var ii=0; ii<basicIds.length; ii++){\n    var el = document.getElementById(basicIds[ii]);\n    if(el){\n      \/\/ Implement width gets a special handler: when wM changes, also\n      \/\/ auto-update the min turn radius via autoTurnRadius(wM). Ag-equipment\n      \/\/ turn radius scales with implement width up to ~24 m (after which the\n      \/\/ tractor's own steering geometry caps it). Keeping r in sync with w\n      \/\/ means a user dragging the width input gets realistic U-turns at every\n      \/\/ size, not a stuck \"9 m default\" that produced racetrack-looking turns\n      \/\/ on narrow implements.\n      var isW = basicIds[ii] === 'gpl-wm';\n      var widthHandler = function(){\n        var wEl = document.getElementById('gpl-wm');\n        var rEl = document.getElementById('gpl-turn-r');\n        var hintEl = document.getElementById('gpl-turn-r-hint');\n        var machineEl = document.getElementById('gpl-machine');\n        if(wEl ? rEl : false){\n          var wDisp = parseFloat(wEl.value);\n          if(!isNaN(wDisp) ? wDisp > 0 : false){\n            var wM = unitSystem === 'us' ? wDisp \/ M_TO_FT : wDisp;\n            \/\/ Use the active machine class's width-to-turn-radius factor when\n            \/\/ available, so changing the width on a \"tractor + 24-row planter\"\n            \/\/ preset gives a TRAILED-PLANTER-realistic turn radius (~1.0 \u00d7 w)\n            \/\/ instead of the generic mounted-implement 0.75 \u00d7 w.\n            var mSpec = machineEl ? MACHINE_SPEC[machineEl.value] : null;\n            var factor = mSpec ? (typeof mSpec.factor === 'number' ? mSpec.factor : 0.75) : 0.75;\n            var autoRm = autoTurnRadius(wM, factor);\n            var autoRdisp = unitSystem === 'us' ? autoRm * M_TO_FT : autoRm;\n            rEl.value = String(unitSystem === 'us' ? autoRdisp.toFixed(1) : autoRm);\n            if(hintEl){\n              hintEl.textContent = 'auto \u00b7 ' + fmtWidth(autoRm) + ' for ' + fmtWidth(wM) + ' implement';\n            }\n          }\n        }\n        updateHeadlandLabel();\n        scheduleRecompute();\n      };\n      var basicHandler = function(){\n        updateHeadlandLabel();\n        scheduleRecompute();\n      };\n      if(isW){\n        el.addEventListener('input', widthHandler);\n        \/\/ 'change' fires on blur, Enter, and spinner clicks \u2014 in case the\n        \/\/ user commits a value without triggering the debounced 'input'\n        \/\/ path, run recompute immediately so the lines visibly redraw.\n        el.addEventListener('change', function(){\n          widthHandler();\n          \/\/ 'change' implies the user is done editing \u2014 bypass the\n          \/\/ 500 ms debounce so they see the result without lag.\n          if(recomputeTimer){\n            clearTimeout(recomputeTimer);\n            recomputeTimer = null;\n          }\n          recompute();\n        });\n      } else {\n        el.addEventListener('input', basicHandler);\n        el.addEventListener('change', basicHandler);\n      }\n    }\n  }\n  \/\/ Headland slider \u2014 drives headland = mult \u00d7 wM. Slider drag fires bursts,\n  \/\/ use the fast (120 ms) debounce so the drag feels live.\n  var hlSlider = document.getElementById('gpl-hl-mult');\n  if(hlSlider){\n    hlSlider.addEventListener('input', function(){\n      updateHeadlandLabel();\n      scheduleRecomputeFast();\n    });\n  }\n  \/\/ ROI inputs \u2014 purely informational, debounce so typing doesn't thrash.\n  var roiIds = ['gpl-roi-farm', 'gpl-roi-apps'];\n  for(var roi=0; roi<roiIds.length; roi++){\n    var roiEl = document.getElementById(roiIds[roi]);\n    if(roiEl) roiEl.addEventListener('input', scheduleRecompute);\n  }\n  \/\/ Turn-around controls \u2014 'input' is the live-typing event (debounce),\n  \/\/ 'change' fires on blur \/ dropdown commit (run immediately so the user\n  \/\/ sees the final state without waiting for the trailing 80 ms timeout).\n  \/\/ For the turn-r input we additionally surface an \"unrealistic-tight\"\n  \/\/ hint when the typed radius is well below the machine's realistic\n  \/\/ minimum (8 m for tractors \/ trailed implements). Small radii produce\n  \/\/ visually clean U-turns but don't reflect what a real trailed planter\n  \/\/ can physically execute.\n  function updateTurnRadiusHint(){\n    var rEl = document.getElementById('gpl-turn-r');\n    var hintEl = document.getElementById('gpl-turn-r-hint');\n    var mEl = document.getElementById('gpl-machine');\n    if(!rEl ? true : !hintEl) return;\n    var raw = parseFloat(rEl.value);\n    if(isNaN(raw) ? true : raw <= 0) return;\n    var rM = unitSystem === 'us' ? raw \/ M_TO_FT : raw;\n    var spec = mEl ? MACHINE_SPEC[mEl.value] : null;\n    var safeMin = spec ? (spec.r > 0 ? spec.r * 0.7 : 8) : 8;  \/\/ 70% of the preset minimum is the \"tight but possible\" threshold\n    if(rM < safeMin){\n      hintEl.textContent = '\u26a0 Tight turn \u2014 may be unrealistic for this machine';\n      hintEl.style.color = '#dc2626';\n    } else {\n      hintEl.textContent = spec ? ('auto \u00b7 ' + spec.label) : ('auto \u00b7 ' + fmtWidth(rM));\n      hintEl.style.color = '';\n    }\n  }\n  var turnIds = ['gpl-turn-style', 'gpl-turn-r'];\n  for(var ti=0; ti<turnIds.length; ti++){\n    var tEl = document.getElementById(turnIds[ti]);\n    if(tEl){\n      var isTurnR = turnIds[ti] === 'gpl-turn-r';\n      tEl.addEventListener('input', isTurnR ? function(){\n        updateTurnRadiusHint();\n        scheduleRecompute();\n      } : scheduleRecompute);\n      tEl.addEventListener('change', isTurnR ? function(){\n        updateTurnRadiusHint();\n        recompute();\n      } : recompute);\n    }\n  }\n  \/\/ Machine type \u2014 auto-fills BOTH turn radius and implement width (unless\n  \/\/ 'custom'). Each machine class has typical specs from manufacturer averages.\n  function applyMachineRadius(){\n    var mEl = document.getElementById('gpl-machine');\n    var rEl = document.getElementById('gpl-turn-r');\n    var wEl = document.getElementById('gpl-wm');\n    var hintEl = document.getElementById('gpl-turn-r-hint');\n    if(!mEl ? true : !rEl) return;\n    var spec = MACHINE_SPEC[mEl.value] || MACHINE_SPEC['tractor-std'];\n    \/\/ Spec is in metric; convert to display units if the user has picked US.\n    if(mEl.value !== 'custom' ? spec.r > 0 : false){\n      var rDisp = unitSystem === 'us' ? spec.r * M_TO_FT : spec.r;\n      rEl.value = String(unitSystem === 'us' ? rDisp.toFixed(1) : spec.r);\n      rEl.disabled = false;\n    }\n    if(mEl.value !== 'custom' ? (spec.w > 0 ? wEl : false) : false){\n      var wDisp = unitSystem === 'us' ? spec.w * M_TO_FT : spec.w;\n      wEl.value = String(unitSystem === 'us' ? wDisp.toFixed(0) : spec.w);\n    }\n    \/\/ Sync headland slider\/label to the new width so headlandM stays at the\n    \/\/ user's intended multiplier of pass width.\n    updateHeadlandLabel();\n    if(hintEl){\n      hintEl.textContent = mEl.value === 'custom' ? 'manual override' : ('auto \u00b7 ' + spec.label);\n    }\n  }\n  var machineSelEl = document.getElementById('gpl-machine');\n  if(machineSelEl){\n    machineSelEl.addEventListener('change', function(){\n      applyMachineRadius();\n      recompute();\n    });\n  }\n  \/\/ Currency select \u2014 purely a label switch; user types their local price\n  var curSelEl = document.getElementById('gpl-currency');\n  if(curSelEl) curSelEl.addEventListener('change', recompute);\n  \/\/ File-import helpers \u2014 accept GeoJSON, KML, or zipped shapefile. Extract\n  \/\/ ALL polygon outer rings (so users can iterate through fields in a\n  \/\/ multi-feature file), project lat\/lng to local meters with each polygon's\n  \/\/ own centroid as origin, fit into the existing canvas coordinate scheme.\n  \/\/ Projection origin captured when a real upload comes in. Used by the\n  \/\/ GIS export to invert canvas coords back to lng\/lat. For sample\n  \/\/ fields we seed a plausible US-corn-belt origin so the export is\n  \/\/ still a valid geographic file (relative geometry preserved; the\n  \/\/ operator's machine doesn't care about absolute position as long as\n  \/\/ pass spacing + heading are correct).\n  var EXPORT_ORIGIN_LNG = -92.5;   \/\/ central Iowa default\n  var EXPORT_ORIGIN_LAT = 41.5;\n  var EXPORT_COSLAT = Math.cos(EXPORT_ORIGIN_LAT * Math.PI \/ 180);\n  var EXPORT_SHIFT_X = 380;        \/\/ canvas centre default\n  var EXPORT_SHIFT_Y = 290;\n  function canvasToLngLat(x, y){\n    var mx = x - EXPORT_SHIFT_X;\n    var my = y - EXPORT_SHIFT_Y;\n    var lng = EXPORT_ORIGIN_LNG + mx \/ (111320 * EXPORT_COSLAT);\n    var lat = EXPORT_ORIGIN_LAT - my \/ 111320;\n    return [lng, lat];\n  }\n  \/\/ Imported polygons live in `importedFields` (array of { name, coords }\n  \/\/ where coords is canvas-meter {x,y} points). Iterator UI lets the user\n  \/\/ step through them.\n  var importedFields = [];\n  var importedIdx = 0;\n  \/\/ Lazy-load shpjs from jsDelivr on demand for .zip \/ .shp inputs. The\n  \/\/ promise is cached so subsequent uploads do not re-fetch the library.\n  \/\/ This is the only network dependency in the tool \u2014 opt-in via upload.\n  var shpjsPromise = null;\n  function loadShpjs(){\n    if(typeof window.shp === 'function') return Promise.resolve();\n    if(shpjsPromise) return shpjsPromise;\n    shpjsPromise = new Promise(function(resolve, reject){\n      var s = document.createElement('script');\n      \/\/ CDN URL has no query separators, so rule 1 is not at risk here.\n      s.src = 'https:\/\/cdn.jsdelivr.net\/npm\/shpjs@4.0.4\/dist\/shp.min.js';\n      s.onload = function(){ resolve(); };\n      s.onerror = function(){ shpjsPromise = null; reject(new Error('Could not load shapefile parser')); };\n      document.head.appendChild(s);\n    });\n    return shpjsPromise;\n  }\n  \/\/ Extract every polygon outer ring from any GeoJSON object. Returns\n  \/\/ [{ name, coords: [[lng,lat], \u2026] }, \u2026] sorted by area descending so the\n  \/\/ biggest polygon (usually the field of interest) comes first.\n  \/\/ Extract LineString \/ MultiLineString features from a GeoJSON object.\n  \/\/ Used when the user uploads existing GUIDANCE LINES (not a boundary):\n  \/\/ the tool derives a field boundary via convex hull of the line endpoints\n  \/\/ and visualises the lines + compares vs the proposed plan.\n  function extractLinesGeoJSON(obj){\n    var lines = [];\n    \/\/ Real ag files store guidance lines in two common shapes:\n    \/\/\n    \/\/   \u2022 AB-line plans \u2014 N parallel straight 2-vertex LineStrings (just\n    \/\/     pass start + end coords, no GPS noise). This is the OPERATOR's\n    \/\/     PLAN: where they intended to drive. Each line is a real body\n    \/\/     pass at the configured implement width.\n    \/\/\n    \/\/   \u2022 Recorded GPS tracks \u2014 many-vertex LineStrings (sometimes a\n    \/\/     single feature winds through headland + body, sometimes one\n    \/\/     feature per pass).\n    \/\/\n    \/\/ We don't try to infer which is which any more (the earlier\n    \/\/ segment_type filter was wrong on the lv Eichwiese sample where\n    \/\/ 16 two-vertex lines are the actual body-pass plan and the one\n    \/\/ 287-vertex feature is a separate headland trace). All LineString\n    \/\/ features are treated as driven passes. Stats reflect the total\n    \/\/ line geometry \u2014 short straight passes give low total km because\n    \/\/ the file IS short, not because we're filtering. The Analytics\n    \/\/ hint surfaces that limitation honestly.\n    function pushFeature(f){\n      if(!f ? true : !f.geometry) return;\n      var gt = f.geometry.type;\n      if(gt === 'LineString' ? f.geometry.coordinates.length >= 2 : false){\n        lines.push(f.geometry.coordinates.slice());\n      } else if(gt === 'MultiLineString'){\n        for(var ii=0; ii<f.geometry.coordinates.length; ii++){\n          var seg = f.geometry.coordinates[ii];\n          if(seg.length >= 2) lines.push(seg.slice());\n        }\n      }\n    }\n    if(obj.type === 'FeatureCollection' ? obj.features : false){\n      for(var k=0; k<obj.features.length; k++) pushFeature(obj.features[k]);\n    } else if(obj.type === 'Feature'){\n      pushFeature(obj);\n    } else if(obj.type === 'LineString' ? true : obj.type === 'MultiLineString'){\n      pushFeature({ geometry: obj });\n    }\n    return lines;\n  }\n  \/\/ Auto-detect the implement width from uploaded body AB lines. Each\n  \/\/ short polyline (2-10 vertices) is one body pass; the gap between\n  \/\/ adjacent parallel passes IS the implement width. Method:\n  \/\/   1. Filter to short polylines (skip headland traces, which would\n  \/\/      bias the dominant-axis fit).\n  \/\/   2. Sum oriented line directions \u2192 dominant axis ux\/uy.\n  \/\/   3. Project each line's midpoint onto the perpendicular axis (px\/py).\n  \/\/   4. Sort projections, take consecutive gaps.\n  \/\/   5. Drop near-zero gaps (back-and-forth duplicates of the same pass).\n  \/\/   6. Take the median of the SMALLEST third of remaining gaps \u2014 these\n  \/\/      are the true adjacent-pass spacings (any larger gaps are skipped\n  \/\/      passes or obstacles).\n  \/\/ Returns metres, or null when too few lines to estimate.\n  \/\/ Filter lines down to actually-driven tracks. AB-reference markers\n  \/\/ (line.driven === false) get excluded from width \/ ring detection\n  \/\/ so they don't contaminate the inferred implement parameters.\n  function drivenLinesOnly(lines){\n    if(!lines) return [];\n    var out = [];\n    for(var di=0; di<lines.length; di++){\n      if(lines[di].driven === false) continue;\n      out.push(lines[di]);\n    }\n    return out;\n  }\n  \/\/ Is a polyline a closed loop (first vertex coincides with last)?\n  \/\/ Real-world export files (lv Eichwiese sample) often include a\n  \/\/ closed-loop headland-ring REFERENCE as a separate feature on top\n  \/\/ of the actual body passes. Treating that loop as a driven pass\n  \/\/ paints swath around the entire boundary even though the\n  \/\/ operator's plan only covered body \u2014 reported 2026-06-03.\n  \/\/\n  \/\/ RULE: closed loops in imported line files are NEVER actually\n  \/\/ driven body passes. They're either (a) the boundary itself, or\n  \/\/ (b) a headland-ring reference. The body-pass clusterer +\n  \/\/ playback + Compare All \"Your current lines\" must all skip them.\n  \/\/ `countInnerHeadlandRings` is the one allowed exception \u2014 its\n  \/\/ entire job is to count closed loops to seed the headland slider.\n  function isImportedLineLoop(line, gapTolM){\n    if(!line ? true : line.length < 3) return false;\n    var tol = (typeof gapTolM === 'number' ? gapTolM : null);\n    if(tol === null){\n      \/\/ No explicit tolerance \u2192 derive from the current implement\n      \/\/ width input (closed enough = within one swath of slop).\n      var wmEl = document.getElementById('gpl-wm');\n      var wMRef = parseFloat((wmEl || {}).value) || 18;\n      if(unitSystem === 'us') wMRef = wMRef \/ M_TO_FT;\n      tol = wMRef;\n    }\n    var dx = line[0].x - line[line.length - 1].x;\n    var dy = line[0].y - line[line.length - 1].y;\n    return (dx * dx + dy * dy) < tol * tol;\n  }\n  \/\/ Filter to lines that should be treated as driven body passes:\n  \/\/ driven===true (or unset) AND not a closed loop. Used by playback,\n  \/\/ Compare All \"Your current lines\", and any other consumer that\n  \/\/ wants the operator's ACTUAL plan, not reference geometry.\n  function bodyPassesOnly(lines, gapTolM){\n    var driven = drivenLinesOnly(lines);\n    var out = [];\n    for(var bi=0; bi<driven.length; bi++){\n      if(isImportedLineLoop(driven[bi], gapTolM)) continue;\n      out.push(driven[bi]);\n    }\n    return out;\n  }\n  function detectImplementWidthFromLines(lines){\n    lines = drivenLinesOnly(lines);\n    var bodyLines = [];\n    for(var i=0; i<lines.length; i++){\n      if(lines[i].length >= 2 ? lines[i].length <= 10 : false) bodyLines.push(lines[i]);\n    }\n    if(bodyLines.length < 3) return null;\n    var sumDx = 0, sumDy = 0;\n    for(var li=0; li<bodyLines.length; li++){\n      var ln = bodyLines[li];\n      var dx = ln[ln.length - 1].x - ln[0].x;\n      var dy = ln[ln.length - 1].y - ln[0].y;\n      var len = Math.sqrt(dx * dx + dy * dy);\n      if(len < 1) continue;\n      if(sumDx * dx + sumDy * dy < 0){ dx = -dx; dy = -dy; }\n      sumDx += dx; sumDy += dy;\n    }\n    var axLen = Math.sqrt(sumDx * sumDx + sumDy * sumDy);\n    if(axLen < 1) return null;\n    var ux = sumDx \/ axLen, uy = sumDy \/ axLen;\n    var px = -uy, py = ux;\n    var perps = [];\n    for(var li2=0; li2<bodyLines.length; li2++){\n      var ln2 = bodyLines[li2];\n      var mx = (ln2[0].x + ln2[ln2.length - 1].x) * 0.5;\n      var my = (ln2[0].y + ln2[ln2.length - 1].y) * 0.5;\n      perps.push(mx * px + my * py);\n    }\n    perps.sort(function(a, b){ return a - b; });\n    var gaps = [];\n    for(var g=1; g<perps.length; g++){\n      var gap = perps[g] - perps[g - 1];\n      if(gap > 1) gaps.push(gap);  \/\/ drop forward+back duplicates of same pass\n    }\n    if(gaps.length === 0) return null;\n    gaps.sort(function(a, b){ return a - b; });\n    \/\/ Median of the smallest 1\/3 \u2014 adjacent-pass spacings cluster at the\n    \/\/ low end; bigger gaps are skipped passes \/ obstacle bypasses.\n    var smallCount = Math.max(1, Math.floor(gaps.length \/ 3));\n    var medianSmall = gaps[Math.floor(smallCount \/ 2)];\n    if(medianSmall < 3 ? true : medianSmall > 60) return null;\n    return medianSmall;\n  }\n  \/\/ Count closed-loop headland-ring polylines in the upload. Each\n  \/\/ closed loop represents ONE driven perimeter pass the operator\n  \/\/ recorded. Auto-feeds the headland-multiplier slider so the\n  \/\/ simulator reproduces the same headland coverage as the file.\n  \/\/ Reported 2026-06-03: lv Eichwiese has 1 closed loop and the\n  \/\/ slider stayed at 0 \u2014 should be 1 (the loop = one headland ring).\n  \/\/ The earlier `n - 1` formula subtracted the outer loop as \"the\n  \/\/ boundary itself\", but with concave-hull boundary derivation the\n  \/\/ loop is BOTH the boundary AND a driven ring (operator drove the\n  \/\/ perimeter once). Counting it as 1 matches operator intent.\n  function countInnerHeadlandRings(lines){\n    lines = drivenLinesOnly(lines);\n    var n = 0;\n    for(var i=0; i<lines.length; i++){\n      var ln = lines[i];\n      if(ln.length < 20) continue;\n      var f = ln[0], l = ln[ln.length - 1];\n      var closeGap2 = (f.x - l.x) * (f.x - l.x) + (f.y - l.y) * (f.y - l.y);\n      if(closeGap2 < 25) n++;  \/\/ closed loop = headland ring\n    }\n    return n;\n  }\n  \/\/ Does a closed polygon (last vertex != first) have any pair of non-\n  \/\/ adjacent edges that cross? Used to validate the concave-hull output\n  \/\/ before installing it as BOUNDARY.\n  function polygonSelfIntersects(poly){\n    var n = poly.length;\n    if(n < 4) return false;\n    function segCross(ax, ay, bx, by, cx, cy, dx, dy){\n      var s1x = bx - ax, s1y = by - ay;\n      var s2x = dx - cx, s2y = dy - cy;\n      var denom = (-s2x * s1y + s1x * s2y);\n      if(Math.abs(denom) < 1e-9) return false;\n      var s = (-s1y * (ax - cx) + s1x * (ay - cy)) \/ denom;\n      var t = ( s2x * (ay - cy) - s2y * (ax - cx)) \/ denom;\n      return s > 1e-6 ? s < 1 - 1e-6 ? t > 1e-6 ? t < 1 - 1e-6 : false : false : false;\n    }\n    for(var i=0; i<n; i++){\n      var i2 = (i + 1) % n;\n      for(var j=i+2; j<n; j++){\n        var j2 = (j + 1) % n;\n        if(i === j2) continue;  \/\/ wraparound adjacent edge\n        var a = poly[i], b = poly[i2], c = poly[j], d = poly[j2];\n        if(segCross(a.x, a.y, b.x, b.y, c.x, c.y, d.x, d.y)) return true;\n      }\n    }\n    return false;\n  }\n  \/\/ Repair a self-intersecting polygon by clipping out the self-crossing\n  \/\/ loops. On boundaries with sharp acute tips (lv Landgut upper-left V)\n  \/\/ the inward-offset polygon folds back on itself near the apex \u2014\n  \/\/ offsetPolygonInward emits a \"ring\" with a small loop where the two\n  \/\/ offset edges from the V's arms cross. The bulk of the polygon is\n  \/\/ valid; we want to keep that and excise the loop.\n  \/\/\n  \/\/ Algorithm: find the first pair of non-adjacent edges that cross.\n  \/\/ Splitting at the crossing point produces two sub-polygons; keep the\n  \/\/ one with larger absolute area (the OUTER bulk, since the offending\n  \/\/ loop is the smaller side near the tip). Repeat up to MAX iterations\n  \/\/ (so a pathological input can't cause unbounded work). Reported\n  \/\/ 2026-06-04 \u2014 lv Landgut headland ring + strip were both skipped\n  \/\/ because the FIRST ring already self-intersected at the tip.\n  function repairSelfIntersections(poly){\n    function ringArea(p){\n      var s = 0;\n      for(var ai=0; ai<p.length; ai++){\n        var aj = (ai + 1) % p.length;\n        s += p[ai].x * p[aj].y - p[aj].x * p[ai].y;\n      }\n      return Math.abs(s) * 0.5;\n    }\n    function segIxParam(ax, ay, bx, by, cx, cy, dx, dy){\n      var s1x = bx - ax, s1y = by - ay;\n      var s2x = dx - cx, s2y = dy - cy;\n      var denom = (-s2x * s1y + s1x * s2y);\n      if(Math.abs(denom) < 1e-9) return null;\n      var s = (-s1y * (ax - cx) + s1x * (ay - cy)) \/ denom;\n      var t = ( s2x * (ay - cy) - s2y * (ax - cx)) \/ denom;\n      if(s <= 1e-6 ? true : s >= 1 - 1e-6) return null;\n      if(t <= 1e-6 ? true : t >= 1 - 1e-6) return null;\n      return { s: s, t: t, x: ax + s1x * s, y: ay + s1y * s };\n    }\n    var current = poly.slice();\n    for(var iter=0; iter<20; iter++){\n      var n = current.length;\n      if(n < 4) break;\n      var found = null;\n      outer: for(var i=0; i<n; i++){\n        var i2 = (i + 1) % n;\n        for(var j=i+2; j<n; j++){\n          var j2 = (j + 1) % n;\n          if(i === j2) continue;\n          var ix = segIxParam(\n            current[i].x, current[i].y, current[i2].x, current[i2].y,\n            current[j].x, current[j].y, current[j2].x, current[j2].y\n          );\n          if(ix){ found = { i: i, j: j, ix: ix }; break outer; }\n        }\n      }\n      if(!found) return current;\n      \/\/ Build two sub-polygons split at the crossing:\n      \/\/   loopA = vertices (i+1) .. j  + the crossing point\n      \/\/   loopB = vertices (j+1) .. i  + the crossing point\n      var loopA = [{ x: found.ix.x, y: found.ix.y }];\n      var k = (found.i + 1) % n;\n      while(true){\n        loopA.push({ x: current[k].x, y: current[k].y });\n        if(k === found.j) break;\n        k = (k + 1) % n;\n      }\n      var loopB = [{ x: found.ix.x, y: found.ix.y }];\n      k = (found.j + 1) % n;\n      while(true){\n        loopB.push({ x: current[k].x, y: current[k].y });\n        if(k === found.i) break;\n        k = (k + 1) % n;\n      }\n      \/\/ Keep the larger-area side.\n      current = ringArea(loopA) > ringArea(loopB) ? loopA : loopB;\n    }\n    return current;\n  }\n  \/\/ Derive a CONCAVE field boundary from uploaded line data.\n  \/\/\n  \/\/ STRATEGY 1 \u2014 headland-ring polyline (preferred when present).\n  \/\/ Many AB-line exports include the operator's headland boundary trace\n  \/\/ as a high-vertex CLOSED polyline (first vertex = last vertex). If one\n  \/\/ or more such rings exist in the upload, pick the OUTERMOST (largest\n  \/\/ bbox) and use its vertices directly as the field boundary. That ring\n  \/\/ already IS the field outline drawn by the operator \u2014 no hulling\n  \/\/ needed, no information lost. Concentric inner rings (multi-ring\n  \/\/ headlands) are deliberately ignored \u2014 the body-pass algorithm will\n  \/\/ regenerate them at the chosen implement width.\n  \/\/\n  \/\/ STRATEGY 2 \u2014 parallel AB-line endpoints (fallback for line-only files).\n  \/\/ Each line has two endpoints on opposite headland edges; order the\n  \/\/ starts ascending by perpendicular position and the ends descending,\n  \/\/ trace the polygon. Recovers L-shapes \/ trapezoids that convex hull\n  \/\/ would round off. Falls back to convex hull if the polygon\n  \/\/ self-intersects (non-parallel mixed-orientation lines).\n  \/\/ Expand a closed-loop polyline OUTWARD by distM. Used to recover the\n  \/\/ true field boundary when a headland trace (operator's wheel-track\n  \/\/ centerline driving the perimeter) is the only \"boundary\" available\n  \/\/ in an imported file. The actual field edge sits one half-implement-\n  \/\/ width OUTSIDE the trace, so distM \u2248 wM\/2 gives the agronomic\n  \/\/ boundary (reported 2026-06-04 \u2014 \"simulated boundary should be\n  \/\/ bigger by half implement width\"). Each vertex shifts along the\n  \/\/ average outward normal of adjacent edges; sharp corners get a\n  \/\/ simple averaged-normal shift (no miter cap \u2014 sufficient for\n  \/\/ smoothly-traced GPS loops typical of headland recordings).\n  function expandLoopOutward(loop, distM){\n    var n = loop ? loop.length : 0;\n    if(n < 3 ? true : distM <= 0) return loop;\n    var s = 0;\n    for(var i=0; i<n; i++){ var j=(i+1)%n; s += loop[i].x*loop[j].y - loop[j].x*loop[i].y; }\n    var sign = s > 0 ? 1 : -1;\n    var out = [];\n    for(var v=0; v<n; v++){\n      var p = loop[(v - 1 + n) % n];\n      var c = loop[v];\n      var nx = loop[(v + 1) % n];\n      var e1x = c.x - p.x, e1y = c.y - p.y;\n      var e2x = nx.x - c.x, e2y = nx.y - c.y;\n      var l1 = Math.sqrt(e1x*e1x + e1y*e1y); if(l1 < 1e-6) l1 = 1;\n      var l2 = Math.sqrt(e2x*e2x + e2y*e2y); if(l2 < 1e-6) l2 = 1;\n      var n1x = (e1y \/ l1) * sign, n1y = -(e1x \/ l1) * sign;\n      var n2x = (e2y \/ l2) * sign, n2y = -(e2x \/ l2) * sign;\n      var avgX = (n1x + n2x) * 0.5;\n      var avgY = (n1y + n2y) * 0.5;\n      var al = Math.sqrt(avgX*avgX + avgY*avgY);\n      if(al < 1e-6){ out.push({ x: c.x, y: c.y }); continue; }\n      \/\/ Miter cap: scale by 1\/cos(half-angle) so the expansion at sharp\n      \/\/ corners actually sits distM out along the bisector.\n      var cosFull = n1x*n2x + n1y*n2y;\n      var cosHalf = Math.sqrt(Math.max(0, (1 + cosFull) * 0.5));\n      if(cosHalf < 0.5) cosHalf = 0.5;\n      out.push({\n        x: c.x + (avgX \/ al) * (distM \/ cosHalf),\n        y: c.y + (avgY \/ al) * (distM \/ cosHalf)\n      });\n    }\n    return out;\n  }\n  function concaveHullFromLines(lines, allPts, expandOutwardM){\n    if(!lines ? true : lines.length < 2) return convexHull(allPts);\n    \/\/ ----- Strategy 1: outermost closed headland ring -----\n    \/\/ A polyline is a candidate boundary ring when it has \u2265 20 vertices,\n    \/\/ its first vertex coincides with its last (closed loop), and its\n    \/\/ bbox covers \u2265 half of allPts' bbox (so we don't pick up a small\n    \/\/ inner obstacle ring as the field outline).\n    var allMnx = Infinity, allMxx = -Infinity, allMny = Infinity, allMxy = -Infinity;\n    for(var ap=0; ap<allPts.length; ap++){\n      var pp = allPts[ap];\n      if(pp.x < allMnx) allMnx = pp.x;\n      if(pp.x > allMxx) allMxx = pp.x;\n      if(pp.y < allMny) allMny = pp.y;\n      if(pp.y > allMxy) allMxy = pp.y;\n    }\n    var allArea = (allMxx - allMnx) * (allMxy - allMny);\n    var bestRing = null, bestRingArea = 0;\n    for(var li0=0; li0<lines.length; li0++){\n      var ln0 = lines[li0];\n      if(ln0.length < 20) continue;\n      var f = ln0[0], l = ln0[ln0.length - 1];\n      var closeGap2 = (f.x - l.x) * (f.x - l.x) + (f.y - l.y) * (f.y - l.y);\n      if(closeGap2 > 25) continue;  \/\/ > 5 m gap = not closed\n      var mnx = Infinity, mxx = -Infinity, mny = Infinity, mxy = -Infinity;\n      for(var rv=0; rv<ln0.length; rv++){\n        var rp = ln0[rv];\n        if(rp.x < mnx) mnx = rp.x;\n        if(rp.x > mxx) mxx = rp.x;\n        if(rp.y < mny) mny = rp.y;\n        if(rp.y > mxy) mxy = rp.y;\n      }\n      var ringArea = (mxx - mnx) * (mxy - mny);\n      \/\/ Must cover at least half the global bbox to be the field outline,\n      \/\/ not an internal obstacle ring.\n      if(allArea > 0 ? ringArea < allArea * 0.5 : false) continue;\n      if(ringArea > bestRingArea){ bestRing = ln0; bestRingArea = ringArea; }\n    }\n    if(bestRing){\n      \/\/ Simplify to ~3 m vertex spacing. A 700\u00d71300 m headland ring sampled\n      \/\/ at 1 Hz can carry 500-800 vertices; offsetPolygonInward + pointInPoly\n      \/\/ + rasterCoverage run repeatedly per recompute and slow noticeably\n      \/\/ past ~150 vertices. 3 m is well below typical implement width so\n      \/\/ no useful field-shape detail is lost.\n      var minStep2 = 9;  \/\/ 3 m\n      \/\/ CLONE each vertex \u2014 the picked ring is one of `projLines`'s\n      \/\/ polylines, and the caller (applyCombinedGeoImport) later shifts\n      \/\/ BOTH projBoundary AND projLines independently. Pushing\n      \/\/ bestRing[bv] by reference caused the same vertex object to land\n      \/\/ in both arrays \u2192 got shifted TWICE \u2192 the loop in UPLOADED_LINES\n      \/\/ drifted (cx, cy) away from the boundary it was derived from,\n      \/\/ making the dashed-blue overlay look projection-broken\n      \/\/ (reported 2026-06-03 on lv Eichwiese).\n      var simp = [{ x: bestRing[0].x, y: bestRing[0].y }];\n      for(var bv=1; bv<bestRing.length; bv++){\n        var pr = simp[simp.length - 1];\n        var ddx = bestRing[bv].x - pr.x, ddy = bestRing[bv].y - pr.y;\n        if(ddx * ddx + ddy * ddy > minStep2) simp.push({ x: bestRing[bv].x, y: bestRing[bv].y });\n      }\n      \/\/ Strip the closing duplicate (so the polygon is open at the seam\n      \/\/ like every other BOUNDARY in the tool).\n      if(simp.length > 3){\n        var sf = simp[0], sl = simp[simp.length - 1];\n        if((sf.x - sl.x) * (sf.x - sl.x) + (sf.y - sl.y) * (sf.y - sl.y) < minStep2) simp.pop();\n      }\n      if(simp.length >= 4 ? !polygonSelfIntersects(simp) : false){\n        \/\/ Expand the loop outward by wM\/2 (or whatever caller passed)\n        \/\/ so the boundary sits at the true field edge, not at the\n        \/\/ wheel-track centerline. Skip if the result self-intersects\n        \/\/ (degenerate input \u2014 fall back to the un-expanded loop).\n        if(expandOutwardM > 0){\n          var expanded = expandLoopOutward(simp, expandOutwardM);\n          if(expanded ? expanded.length >= 4 : false){\n            if(!polygonSelfIntersects(expanded)) return expanded;\n          }\n        }\n        return simp;\n      }\n      \/\/ Fall through to Strategy 2 if the headland trace was self-\n      \/\/ intersecting (very unusual \u2014 would mean the operator's drive\n      \/\/ crossed itself).\n    }\n    \/\/ ----- Strategy 2: parallel-AB-line endpoint polygon -----\n    \/\/ Determine the dominant pass direction by summing oriented line\n    \/\/ vectors. Each line gets its direction flipped if needed so all\n    \/\/ contribute the same sign. Skip high-vertex polylines (those are\n    \/\/ headland traces, not body passes) so the axis fit isn't biased.\n    var sumDx = 0, sumDy = 0;\n    for(var li=0; li<lines.length; li++){\n      var ln = lines[li];\n      if(ln.length < 2 ? true : ln.length > 10) continue;\n      var dx0 = ln[ln.length - 1].x - ln[0].x;\n      var dy0 = ln[ln.length - 1].y - ln[0].y;\n      var len0 = Math.sqrt(dx0 * dx0 + dy0 * dy0);\n      if(len0 < 1) continue;\n      if(sumDx * dx0 + sumDy * dy0 < 0){ dx0 = -dx0; dy0 = -dy0; }\n      sumDx += dx0; sumDy += dy0;\n    }\n    var axLen = Math.sqrt(sumDx * sumDx + sumDy * sumDy);\n    if(axLen < 1) return convexHull(allPts);\n    var ux = sumDx \/ axLen, uy = sumDy \/ axLen;\n    var px = -uy, py = ux;  \/\/ perpendicular (right-hand)\n    \/\/ Build start + end point arrays with consistent orientation.\n    \/\/ Only short polylines (\u2264 10 vertices) feed the endpoint polygon \u2014\n    \/\/ multi-vertex headland traces would contribute irrelevant points.\n    var startPts = [], endPts = [];\n    for(var li2=0; li2<lines.length; li2++){\n      var ln2 = lines[li2];\n      if(ln2.length < 2 ? true : ln2.length > 10) continue;\n      var s = ln2[0], e = ln2[ln2.length - 1];\n      var alongS = s.x * ux + s.y * uy;\n      var alongE = e.x * ux + e.y * uy;\n      \/\/ Whichever endpoint projects FARTHER along the dominant axis is the\n      \/\/ \"end\"; the other is the \"start\". Same convention for every line so\n      \/\/ starts cluster on one headland, ends on the opposite headland.\n      if(alongS < alongE){ startPts.push(s); endPts.push(e); }\n      else { startPts.push(e); endPts.push(s); }\n    }\n    if(startPts.length < 2) return convexHull(allPts);\n    \/\/ Order both arrays by perpendicular projection so adjacent points in\n    \/\/ each array are adjacent passes on the field.\n    function perpKey(p){ return p.x * px + p.y * py; }\n    startPts.sort(function(a, b){ return perpKey(a) - perpKey(b); });\n    endPts.sort(function(a, b){ return perpKey(a) - perpKey(b); });\n    var boundary = startPts.slice();\n    for(var ei=endPts.length-1; ei>=0; ei--) boundary.push(endPts[ei]);\n    if(boundary.length < 4) return convexHull(allPts);\n    \/\/ Validate: no self-intersection AND non-trivial area.\n    var area = 0;\n    for(var bi=0; bi<boundary.length; bi++){\n      var bj = (bi + 1) % boundary.length;\n      area += boundary[bi].x * boundary[bj].y - boundary[bj].x * boundary[bi].y;\n    }\n    if(Math.abs(area) < 100) return convexHull(allPts);\n    if(polygonSelfIntersects(boundary)) return convexHull(allPts);\n    return boundary;\n  }\n  \/\/ 2D convex hull (Andrew's monotone chain) \u2014 used as a fallback when\n  \/\/ the concave-hull algorithm can't produce a valid polygon, and to\n  \/\/ bootstrap any general-purpose endpoint-hulling.\n  function convexHull(pts){\n    if(pts.length < 3) return pts.slice();\n    var sorted = pts.slice().sort(function(a, b){\n      return a.x !== b.x ? a.x - b.x : a.y - b.y;\n    });\n    function chCross(o, a, b){\n      return (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);\n    }\n    var n = sorted.length;\n    var hull = [];\n    \/\/ Lower hull\n    for(var i=0; i<n; i++){\n      while(hull.length >= 2 ? chCross(hull[hull.length-2], hull[hull.length-1], sorted[i]) <= 0 : false){\n        hull.pop();\n      }\n      hull.push(sorted[i]);\n    }\n    var lowerCount = hull.length + 1;\n    \/\/ Upper hull\n    for(var j=n-2; j>=0; j--){\n      while(hull.length >= lowerCount ? chCross(hull[hull.length-2], hull[hull.length-1], sorted[j]) <= 0 : false){\n        hull.pop();\n      }\n      hull.push(sorted[j]);\n    }\n    hull.pop();  \/\/ last point = first point of lower hull\n    return hull;\n  }\n  \/\/ Detect file types we DO NOT support so we can reject them with a\n  \/\/ clear reason \u2014 most common mistake is uploading a zoning prescription\n  \/\/ file (hundreds of small polygons, pixel grids) or a sampling layer\n  \/\/ (Point features for soil samples, yield) instead of a field boundary\n  \/\/ or guidance line file. Reported 2026-06-03.\n  function classifyImport(obj){\n    var pointCount = 0;\n    var polyCount = 0;\n    var lineCount = 0;\n    function visit(g){\n      if(!g) return;\n      if(g.type === 'Point' ? true : g.type === 'MultiPoint'){\n        pointCount += (g.type === 'Point' ? 1 : (g.coordinates ? g.coordinates.length : 0));\n      } else if(g.type === 'LineString'){\n        lineCount++;\n      } else if(g.type === 'MultiLineString'){\n        lineCount += g.coordinates ? g.coordinates.length : 0;\n      } else if(g.type === 'Polygon'){\n        polyCount++;\n      } else if(g.type === 'MultiPolygon'){\n        polyCount += g.coordinates ? g.coordinates.length : 0;\n      } else if(g.type === 'GeometryCollection'){\n        if(g.geometries) for(var gi=0; gi<g.geometries.length; gi++) visit(g.geometries[gi]);\n      }\n    }\n    function pushF(f){ if(f) visit(f.geometry ? f.geometry : f); }\n    if(!obj) return { pointCount: 0, polyCount: 0, lineCount: 0 };\n    if(obj.type === 'FeatureCollection' ? obj.features : false){\n      for(var k=0; k<obj.features.length; k++) pushF(obj.features[k]);\n    } else if(obj.type === 'Feature'){\n      pushF(obj);\n    } else {\n      visit(obj);\n    }\n    return { pointCount: pointCount, polyCount: polyCount, lineCount: lineCount };\n  }\n  \/\/ Hard-reject thresholds \u2014 anything above is almost certainly a\n  \/\/ zoning \/ pixel \/ sampling layer mis-uploaded as a boundary. Real\n  \/\/ operational fields have \u2264 ~10 parts (creek-split, road-split arms);\n  \/\/ anything heavier reads as a prescription \/ zone export.\n  var IMPORT_MAX_POLYGONS = 20;\n  var IMPORT_MAX_POINTS = 5;\n  function explainImportRejection(stats, name){\n    if(stats.pointCount > IMPORT_MAX_POINTS){\n      return name + ' looks like a sampling \/ pixel layer (' + stats.pointCount + ' Point features). The simulator needs a FIELD BOUNDARY polygon or guidance LINES \u2014 not a soil-sampling, yield, or zone-centroid file. Open the layer in GeoPard or QGIS, export the field boundary as a polygon, then re-import.';\n    }\n    if(stats.polyCount > IMPORT_MAX_POLYGONS){\n      return name + ' contains ' + stats.polyCount + ' polygons \u2014 that looks like a zoning \/ prescription \/ pixel map, not a field boundary. The simulator needs ONE field outline (a few parts at most). Dissolve the zones into a single boundary polygon in GeoPard or QGIS, then re-import.';\n    }\n    return null;\n  }\n  function extractAllPolygonsGeoJSON(obj){\n    var polys = [];\n    function pushFeature(f, defaultName){\n      if(!f) return;\n      var props = f.properties || {};\n      var name = props.name || props.Name || props.NAME || props.field || props.Field || props.FIELD || props.id || defaultName;\n      var geom = f.geometry ? f.geometry : (f.type === 'Polygon' ? f : (f.type === 'MultiPolygon' ? f : null));\n      if(!geom) return;\n      \/\/ Emit EVERY ring \u2014 outer AND inner (holes). Previously only\n      \/\/ coordinates[0] (the outer ring) was kept, silently discarding\n      \/\/ holes, which killed obstacle support on real imports (reported\n      \/\/ 2026-06-10 on field 41.22\u2026 \u2014 9 parts + 6 holes lost their holes).\n      \/\/ The inner rings come back through projection \u2192 assignHoles, which\n      \/\/ re-parents each contained ring as a hole of its smallest\n      \/\/ container (rule \u00a76 obstacle_boundary).\n      if(geom.type === 'Polygon'){\n        for(var ri=0; ri<geom.coordinates.length; ri++){\n          polys.push({ name: ri === 0 ? name : (name || 'poly') + ' ring ' + ri, coords: geom.coordinates[ri] });\n        }\n      } else if(geom.type === 'MultiPolygon'){\n        for(var i=0; i<geom.coordinates.length; i++){\n          for(var rj=0; rj<geom.coordinates[i].length; rj++){\n            polys.push({ name: (name || 'poly') + ' #' + (i + 1) + (rj === 0 ? '' : ' ring ' + rj), coords: geom.coordinates[i][rj] });\n          }\n        }\n      }\n    }\n    if(obj.type === 'FeatureCollection' ? obj.features : false){\n      for(var k=0; k<obj.features.length; k++) pushFeature(obj.features[k], 'feature ' + (k + 1));\n    } else if(obj.type === 'Feature'){\n      pushFeature(obj, 'feature 1');\n    } else if(obj.type === 'Polygon' ? true : obj.type === 'MultiPolygon'){\n      pushFeature({ geometry: obj, properties: {} }, 'polygon');\n    }\n    \/\/ Sort by approximate area (cross-product sum of lng\/lat coords \u2014 not\n    \/\/ m\u00b2 but monotonic, so largest polygon still wins).\n    polys.forEach(function(p){\n      var a = 0;\n      for(var i=0; i<p.coords.length-1; i++){\n        a += p.coords[i][0] * p.coords[i+1][1] - p.coords[i+1][0] * p.coords[i][1];\n      }\n      p._areaScore = Math.abs(a);\n    });\n    polys.sort(function(a, b){ return b._areaScore - a._areaScore; });\n    return polys;\n  }\n  function extractAllPolygonsKML(text){\n    if(typeof DOMParser === 'undefined') return [];\n    var doc;\n    try { doc = new DOMParser().parseFromString(text, 'application\/xml'); } catch(e){ return []; }\n    var placemarks = doc.getElementsByTagName('Placemark');\n    var polys = [];\n    function coordsFromText(raw){\n      var pairs = raw.split(\/\\s+\/).map(function(s){ return s.trim(); }).filter(function(s){ return s.length > 0; });\n      var out = [];\n      for(var i=0; i<pairs.length; i++){\n        var parts = pairs[i].split(',');\n        if(parts.length < 2) continue;\n        var lng = parseFloat(parts[0]), lat = parseFloat(parts[1]);\n        if(isNaN(lng) ? true : isNaN(lat)) continue;\n        out.push([lng, lat]);\n      }\n      return out;\n    }\n    if(placemarks.length > 0){\n      for(var pi=0; pi<placemarks.length; pi++){\n        var pm = placemarks[pi];\n        var nameEl = pm.getElementsByTagName('name');\n        var name = (nameEl.length > 0 ? nameEl[0].textContent : 'placemark ' + (pi + 1)) || ('placemark ' + (pi + 1));\n        var coordsEls = pm.getElementsByTagName('coordinates');\n        for(var ci=0; ci<coordsEls.length; ci++){\n          var c = coordsFromText(coordsEls[ci].textContent || '');\n          if(c.length >= 3) polys.push({ name: name + (coordsEls.length > 1 ? ' #' + (ci + 1) : ''), coords: c });\n        }\n      }\n    } else {\n      \/\/ Fallback: bare KML with <coordinates> elsewhere\n      var coordsAll = doc.getElementsByTagName('coordinates');\n      for(var ca=0; ca<coordsAll.length; ca++){\n        var c2 = coordsFromText(coordsAll[ca].textContent || '');\n        if(c2.length >= 3) polys.push({ name: 'polygon ' + (ca + 1), coords: c2 });\n      }\n    }\n    return polys;\n  }\n  \/\/ Convert [lng, lat] array to local canvas-meter coords. Center the field at\n  \/\/ the canvas centroid (~380, 290) so the existing scale logic fits it.\n  function lngLatToCanvas(coords){\n    if(!coords ? true : coords.length < 3) return null;\n    \/\/ Use centroid (average) as the projection origin\n    var oLat = 0, oLng = 0;\n    for(var i=0; i<coords.length; i++){ oLng += coords[i][0]; oLat += coords[i][1]; }\n    oLat \/= coords.length; oLng \/= coords.length;\n    var cosLat = Math.cos(oLat * Math.PI \/ 180);\n    var pts = [];\n    for(var j=0; j<coords.length; j++){\n      var lng = coords[j][0], lat = coords[j][1];\n      var mx = (lng - oLng) * 111320 * cosLat;\n      var my = -(lat - oLat) * 111320;  \/\/ canvas y is +down, so flip\n      pts.push({ x: 380 + mx, y: 290 + my });\n    }\n    \/\/ Strip a trailing duplicate vertex (LinearRings close themselves)\n    var last = pts[pts.length - 1], first = pts[0];\n    if(Math.abs(last.x - first.x) < 0.01 ? Math.abs(last.y - first.y) < 0.01 : false){\n      pts.pop();\n    }\n    return pts.length >= 3 ? pts : null;\n  }\n  \/\/ Project MULTIPLE rings using a COMMON centroid so all parts share the\n  \/\/ same canvas space (so a multi-part field lays out correctly relative to\n  \/\/ itself). Returns array of point arrays, one per input ring (skipping rings\n  \/\/ with < 3 vertices).\n  function lngLatToCanvasMulti(coordsList){\n    if(!coordsList ? true : coordsList.length === 0) return [];\n    var oLat = 0, oLng = 0, total = 0;\n    for(var r=0; r<coordsList.length; r++){\n      var ring = coordsList[r];\n      for(var k=0; k<ring.length; k++){ oLng += ring[k][0]; oLat += ring[k][1]; total++; }\n    }\n    if(total === 0) return [];\n    oLat \/= total; oLng \/= total;\n    var cosLat = Math.cos(oLat * Math.PI \/ 180);\n    \/\/ First pass: project every ring to raw metres.\n    var ringsM = [];\n    for(var rr=0; rr<coordsList.length; rr++){\n      var raw = coordsList[rr];\n      var mpts = [];\n      for(var j=0; j<raw.length; j++){\n        var lng = raw[j][0], lat = raw[j][1];\n        var mx = (lng - oLng) * 111320 * cosLat;\n        var my = -(lat - oLat) * 111320;\n        mpts.push({ x: mx, y: my });\n      }\n      \/\/ Strip trailing duplicate vertex\n      var lastM = mpts[mpts.length - 1], firstM = mpts[0];\n      if(Math.abs(lastM.x - firstM.x) < 0.01 ? Math.abs(lastM.y - firstM.y) < 0.01 : false){\n        mpts.pop();\n      }\n      if(mpts.length >= 3) ringsM.push(mpts);\n    }\n    if(ringsM.length === 0) return [];\n    \/\/ Second pass: compute global bbox + a single offset so EVERY ring is\n    \/\/ translated to the canvas with the SAME origin. (Built-in fields live\n    \/\/ around canvas centre (380, 290) \u2014 the existing fit() in getScale()\n    \/\/ scales the whole layout into the canvas window, so we just need the\n    \/\/ multi-part layout to start centred-ish.)\n    var minX=Infinity,minY=Infinity,maxX=-Infinity,maxY=-Infinity;\n    for(var rg=0; rg<ringsM.length; rg++){\n      for(var rj=0; rj<ringsM[rg].length; rj++){\n        var p = ringsM[rg][rj];\n        if(p.x < minX) minX = p.x; if(p.x > maxX) maxX = p.x;\n        if(p.y < minY) minY = p.y; if(p.y > maxY) maxY = p.y;\n      }\n    }\n    var cx = 380 - (minX + maxX) * 0.5;\n    var cy = 290 - (minY + maxY) * 0.5;\n    \/\/ Capture the projection origin so the GIS export can invert canvas\n    \/\/ coords back to (lng, lat). The shift is relative to the projection\n    \/\/ origin (oLng\/oLat) \u2014 so canvas(x) - shiftX = mx, and\n    \/\/ lng = oLng + mx \/ (111320 * cosLat).\n    EXPORT_ORIGIN_LNG = oLng;\n    EXPORT_ORIGIN_LAT = oLat;\n    EXPORT_COSLAT = cosLat;\n    EXPORT_SHIFT_X = cx;\n    EXPORT_SHIFT_Y = cy;\n    var out = [];\n    for(var rk=0; rk<ringsM.length; rk++){\n      var shifted = [];\n      for(var sk=0; sk<ringsM[rk].length; sk++){\n        shifted.push({ x: ringsM[rk][sk].x + cx, y: ringsM[rk][sk].y + cy });\n      }\n      out.push(shifted);\n    }\n    return out;\n  }\n  \/\/ Signed-area helper \u2014 used to pick the largest part as the \"main\" boundary\n  \/\/ for backward-compat call sites that still expect a single polygon.\n  function polyArea(p){\n    var s = 0;\n    for(var i=0; i<p.length; i++){\n      var j = (i + 1) % p.length;\n      s += p[i].x * p[j].y - p[j].x * p[i].y;\n    }\n    return Math.abs(s) * 0.5;\n  }\n  \/\/ GIS export \u2014 convert the current layout's passes (body + headland\n  \/\/ ring centerlines) into a downloadable file. Formats:\n  \/\/   \u2022 GeoJSON \u2014 universal, accepted by John Deere Operations Center,\n  \/\/     CNH FieldOps, AGCO PTx, QGIS, ArcGIS, Trimble Ag Software.\n  \/\/   \u2022 KML \u2014 Google Earth + most field-management viewers.\n  \/\/   \u2022 Shapefile (zipped .shp\/.shx\/.dbf\/.prj) \u2014 universal GIS standard,\n  \/\/     accepted by every major ag platform.\n  \/\/ Coordinates exported in EPSG:4326 (WGS84 lng\/lat) via the inverse\n  \/\/ of the upload projection. Sample fields use the default Iowa\n  \/\/ origin \u2014 relative geometry preserved.\n  function layoutToLineFeatures(){\n    if(!LAST_LAYOUT) return [];\n    var features = [];\n    var passes = LAST_LAYOUT.passes || [];\n    for(var i=0; i<passes.length; i++){\n      var p = passes[i];\n      var coords = [];\n      if(p.samples ? p.samples.length >= 2 : false){\n        for(var s=0; s<p.samples.length; s++) coords.push(canvasToLngLat(p.samples[s].x, p.samples[s].y));\n      } else if(p.x0 !== undefined){\n        coords.push(canvasToLngLat(p.x0, p.y0));\n        coords.push(canvasToLngLat(p.x1, p.y1));\n      } else continue;\n      if(coords.length < 2) continue;\n      features.push({\n        type: 'Feature',\n        properties: {\n          id: i + 1,\n          name: 'Pass ' + (i + 1),\n          kind: p.kind || 'pass',\n          part: typeof p.partIdx === 'number' ? p.partIdx : 0\n        },\n        geometry: { type: 'LineString', coordinates: coords }\n      });\n    }\n    return features;\n  }\n  function triggerDownload(blob, filename){\n    var url = URL.createObjectURL(blob);\n    var a = document.createElement('a');\n    a.href = url;\n    a.download = filename;\n    document.body.appendChild(a);\n    a.click();\n    document.body.removeChild(a);\n    setTimeout(function(){ URL.revokeObjectURL(url); }, 1500);\n  }\n  function exportGeoJSON(){\n    var features = layoutToLineFeatures();\n    if(features.length === 0){ showUploadStatus('error', 'No lines to export. Generate a layout first.'); return; }\n    var fc = { type: 'FeatureCollection', name: 'geopard-guidance-lines', crs: { type: 'name', properties: { name: 'urn:ogc:def:crs:OGC:1.3:CRS84' } }, features: features };\n    triggerDownload(new Blob([JSON.stringify(fc, null, 2)], { type: 'application\/geo+json' }), 'geopard-guidance-lines.geojson');\n    showUploadStatus('success', features.length + ' lines exported as GeoJSON');\n  }\n  function exportKML(){\n    var features = layoutToLineFeatures();\n    if(features.length === 0){ showUploadStatus('error', 'No lines to export. Generate a layout first.'); return; }\n    var parts = [];\n    parts.push('<?xml version=\"1.0\" encoding=\"UTF-8\"?>');\n    parts.push('<kml xmlns=\"http:\/\/www.opengis.net\/kml\/2.2\"><Document><name>GeoPard guidance lines<\/name>');\n    parts.push('<Style id=\"gpline\"><LineStyle><color>ff0c6af7<\/color><width>3<\/width><\/LineStyle><\/Style>');\n    for(var i=0; i<features.length; i++){\n      var f = features[i];\n      var coords = f.geometry.coordinates.map(function(c){ return c[0] + ',' + c[1] + ',0'; }).join(' ');\n      parts.push('<Placemark><name>' + (f.properties.name || ('Pass ' + (i + 1))) + '<\/name>');\n      parts.push('<styleUrl>#gpline<\/styleUrl>');\n      parts.push('<LineString><coordinates>' + coords + '<\/coordinates><\/LineString><\/Placemark>');\n    }\n    parts.push('<\/Document><\/kml>');\n    triggerDownload(new Blob([parts.join('\\n')], { type: 'application\/vnd.google-earth.kml+xml' }), 'geopard-guidance-lines.kml');\n    showUploadStatus('success', features.length + ' lines exported as KML');\n  }\n  \/\/ Shapefile export \u2014 custom polyline writer. @mapbox\/shp-write 0.4.3\n  \/\/ (the latest release) has a bug emitting LineString geometry: it\n  \/\/ writes numParts equal to numPoints with arbitrary part-start\n  \/\/ offsets, producing a \"Corrupted .shp\" error in ogr2ogr\/QGIS. The\n  \/\/ library hasn't been updated in years so we generate the four\n  \/\/ shapefile components (.shp, .shx, .dbf, .prj) in pure JS and bundle\n  \/\/ them with JSZip ourselves. Format reference: ESRI Shapefile\n  \/\/ Technical Description (1998).\n  var JSZIP_PROMISE = null;\n  function loadJSZip(){\n    if(window.JSZip) return Promise.resolve();\n    if(JSZIP_PROMISE) return JSZIP_PROMISE;\n    JSZIP_PROMISE = new Promise(function(resolve, reject){\n      var s = document.createElement('script');\n      s.src = 'https:\/\/cdn.jsdelivr.net\/npm\/jszip@3.10.1\/dist\/jszip.min.js';\n      s.onload = function(){ resolve(); };\n      s.onerror = function(){ JSZIP_PROMISE = null; reject(new Error('jszip CDN load failed')); };\n      document.head.appendChild(s);\n    });\n    return JSZIP_PROMISE;\n  }\n  \/\/ Build a polyline shapefile (.shp), index (.shx), attribute table\n  \/\/ (.dbf), and projection (.prj) from an array of GeoJSON LineString\n  \/\/ features. All multi-byte ints little-endian unless noted. Numbers\n  \/\/ are float64 LE (x,y for each point). DBF stores the feature\n  \/\/ properties as fixed-width ASCII fields.\n  function buildPolylineShapefile(features){\n    \/\/ \u2500\u2500 Pre-compute record sizes + global bbox \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n    var globalBbox = { xmin: Infinity, ymin: Infinity, xmax: -Infinity, ymax: -Infinity };\n    var records = [];\n    for(var fi=0; fi<features.length; fi++){\n      var f = features[fi];\n      var coords = f.geometry.coordinates;\n      if(!coords ? true : coords.length < 2) continue;\n      var bx = Infinity, by = Infinity, BX = -Infinity, BY = -Infinity;\n      for(var ci=0; ci<coords.length; ci++){\n        var cx = coords[ci][0], cy = coords[ci][1];\n        if(cx < bx) bx = cx; if(cx > BX) BX = cx;\n        if(cy < by) by = cy; if(cy > BY) BY = cy;\n      }\n      if(bx < globalBbox.xmin) globalBbox.xmin = bx;\n      if(by < globalBbox.ymin) globalBbox.ymin = by;\n      if(BX > globalBbox.xmax) globalBbox.xmax = BX;\n      if(BY > globalBbox.ymax) globalBbox.ymax = BY;\n      \/\/ Content length per record (in 16-bit words):\n      \/\/   4 (shape type) + 32 (bbox) + 4 (numParts) + 4 (numPoints) +\n      \/\/   4*numParts + 16*numPoints = 44 + 4 + 16*numPoints bytes\n      var contentBytes = 44 + 4 + 16 * coords.length;  \/\/ numParts=1 fixed\n      records.push({\n        properties: f.properties || {},\n        coords: coords,\n        bbox: { xmin: bx, ymin: by, xmax: BX, ymax: BY },\n        contentBytes: contentBytes,\n        recordOffsetWords: 0  \/\/ filled in below\n      });\n    }\n    \/\/ \u2500\u2500 Write .shp \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n    var shpHeaderBytes = 100;\n    var shpBodyBytes = 0;\n    for(var ri=0; ri<records.length; ri++) shpBodyBytes += 8 + records[ri].contentBytes;\n    var shpBuf = new ArrayBuffer(shpHeaderBytes + shpBodyBytes);\n    var shpView = new DataView(shpBuf);\n    \/\/ File header (100 bytes)\n    shpView.setInt32(0, 9994, false);                       \/\/ file code (BE)\n    \/\/ bytes 4-23 unused (zeros)\n    shpView.setInt32(24, (shpHeaderBytes + shpBodyBytes) \/ 2, false);  \/\/ file length, words (BE)\n    shpView.setInt32(28, 1000, true);                       \/\/ version (LE)\n    shpView.setInt32(32, 3, true);                          \/\/ shape type POLYLINE (LE)\n    shpView.setFloat64(36, globalBbox.xmin === Infinity ? 0 : globalBbox.xmin, true);\n    shpView.setFloat64(44, globalBbox.ymin === Infinity ? 0 : globalBbox.ymin, true);\n    shpView.setFloat64(52, globalBbox.xmax === -Infinity ? 0 : globalBbox.xmax, true);\n    shpView.setFloat64(60, globalBbox.ymax === -Infinity ? 0 : globalBbox.ymax, true);\n    \/\/ zMin\/zMax\/mMin\/mMax all zero for 2D polyline\n    \/\/ Records\n    var pos = shpHeaderBytes;\n    for(var rj=0; rj<records.length; rj++){\n      var r = records[rj];\n      r.recordOffsetWords = pos \/ 2;\n      \/\/ Record header (8 bytes, BE)\n      shpView.setInt32(pos, rj + 1, false);                  \/\/ record number\n      shpView.setInt32(pos + 4, r.contentBytes \/ 2, false);  \/\/ content length, words\n      pos += 8;\n      \/\/ Record content (LE)\n      shpView.setInt32(pos, 3, true);                        \/\/ shape type POLYLINE\n      shpView.setFloat64(pos + 4,  r.bbox.xmin, true);\n      shpView.setFloat64(pos + 12, r.bbox.ymin, true);\n      shpView.setFloat64(pos + 20, r.bbox.xmax, true);\n      shpView.setFloat64(pos + 28, r.bbox.ymax, true);\n      shpView.setInt32(pos + 36, 1, true);                   \/\/ numParts (single part)\n      shpView.setInt32(pos + 40, r.coords.length, true);     \/\/ numPoints\n      shpView.setInt32(pos + 44, 0, true);                   \/\/ parts[0] = 0\n      var po = pos + 48;\n      for(var pi=0; pi<r.coords.length; pi++){\n        shpView.setFloat64(po, r.coords[pi][0], true);       \/\/ x\n        shpView.setFloat64(po + 8, r.coords[pi][1], true);   \/\/ y\n        po += 16;\n      }\n      pos += r.contentBytes;\n    }\n    \/\/ \u2500\u2500 Write .shx (index, 100B header + 8B per record) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n    var shxBuf = new ArrayBuffer(100 + 8 * records.length);\n    var shxView = new DataView(shxBuf);\n    shxView.setInt32(0, 9994, false);\n    shxView.setInt32(24, (100 + 8 * records.length) \/ 2, false);\n    shxView.setInt32(28, 1000, true);\n    shxView.setInt32(32, 3, true);\n    shxView.setFloat64(36, globalBbox.xmin === Infinity ? 0 : globalBbox.xmin, true);\n    shxView.setFloat64(44, globalBbox.ymin === Infinity ? 0 : globalBbox.ymin, true);\n    shxView.setFloat64(52, globalBbox.xmax === -Infinity ? 0 : globalBbox.xmax, true);\n    shxView.setFloat64(60, globalBbox.ymax === -Infinity ? 0 : globalBbox.ymax, true);\n    for(var rk=0; rk<records.length; rk++){\n      shxView.setInt32(100 + rk * 8,     records[rk].recordOffsetWords, false);\n      shxView.setInt32(100 + rk * 8 + 4, records[rk].contentBytes \/ 2,  false);\n    }\n    \/\/ \u2500\u2500 Write .dbf (dBase III) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n    \/\/ Fixed schema: id (N,10,0), name (C,30), kind (C,20), part (N,5,0)\n    var fields = [\n      { name: 'id',   type: 'N', len: 10, dec: 0 },\n      { name: 'name', type: 'C', len: 30, dec: 0 },\n      { name: 'kind', type: 'C', len: 20, dec: 0 },\n      { name: 'part', type: 'N', len: 5,  dec: 0 }\n    ];\n    var dbfHeaderBytes = 32 + 32 * fields.length + 1;\n    var dbfRecBytes = 1;  \/\/ deletion flag\n    for(var fk=0; fk<fields.length; fk++) dbfRecBytes += fields[fk].len;\n    var dbfBytes = dbfHeaderBytes + dbfRecBytes * records.length + 1; \/\/ +1 EOF marker\n    var dbfBuf = new ArrayBuffer(dbfBytes);\n    var dbfView = new DataView(dbfBuf);\n    var dbfBytesU8 = new Uint8Array(dbfBuf);\n    var now = new Date();\n    dbfView.setUint8(0, 0x03);\n    dbfView.setUint8(1, now.getUTCFullYear() - 1900);\n    dbfView.setUint8(2, now.getUTCMonth() + 1);\n    dbfView.setUint8(3, now.getUTCDate());\n    dbfView.setInt32(4, records.length, true);\n    dbfView.setUint16(8,  dbfHeaderBytes, true);\n    dbfView.setUint16(10, dbfRecBytes,    true);\n    \/\/ bytes 12-31 zero\n    \/\/ Field descriptors\n    var fPos = 32;\n    for(var fdi=0; fdi<fields.length; fdi++){\n      var fd = fields[fdi];\n      \/\/ Field name (11 bytes, null-terminated\/padded)\n      var nameBytes = new Array(11).fill(0);\n      for(var nb=0; nb<Math.min(10, fd.name.length); nb++) nameBytes[nb] = fd.name.charCodeAt(nb) % 128;\n      for(var nbi=0; nbi<11; nbi++) dbfView.setUint8(fPos + nbi, nameBytes[nbi]);\n      dbfView.setUint8(fPos + 11, fd.type.charCodeAt(0));\n      \/\/ Bytes 12-15 zero\n      dbfView.setUint8(fPos + 16, fd.len);\n      dbfView.setUint8(fPos + 17, fd.dec);\n      \/\/ Bytes 18-31 zero\n      fPos += 32;\n    }\n    dbfView.setUint8(fPos, 0x0D);  \/\/ header terminator\n    fPos += 1;\n    \/\/ Records\n    function writeFixed(view, offset, len, str, alignRight){\n      for(var k=0; k<len; k++) view.setUint8(offset + k, 0x20);\n      var s = String(str || '');\n      if(s.length > len) s = s.slice(0, len);\n      var startK = alignRight ? (len - s.length) : 0;\n      for(var sk=0; sk<s.length; sk++) view.setUint8(offset + startK + sk, s.charCodeAt(sk) % 128);\n    }\n    for(var ri2=0; ri2<records.length; ri2++){\n      var props = records[ri2].properties;\n      dbfView.setUint8(fPos, 0x20);  \/\/ not deleted\n      var oPos = fPos + 1;\n      for(var fi2=0; fi2<fields.length; fi2++){\n        var fdef = fields[fi2];\n        var rawVal = props[fdef.name];\n        if(rawVal === undefined ? true : rawVal === null) rawVal = '';\n        writeFixed(dbfView, oPos, fdef.len, String(rawVal), fdef.type === 'N');\n        oPos += fdef.len;\n      }\n      fPos += dbfRecBytes;\n    }\n    dbfView.setUint8(fPos, 0x1A);  \/\/ EOF marker\n    \/\/ \u2500\u2500 .prj (WGS84 WKT, text) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n    var prj = 'GEOGCS[\"WGS 84\",DATUM[\"WGS_1984\",SPHEROID[\"WGS 84\",6378137,298.257223563,AUTHORITY[\"EPSG\",\"7030\"]],AUTHORITY[\"EPSG\",\"6326\"]],PRIMEM[\"Greenwich\",0,AUTHORITY[\"EPSG\",\"8901\"]],UNIT[\"degree\",0.0174532925199433,AUTHORITY[\"EPSG\",\"9122\"]],AUTHORITY[\"EPSG\",\"4326\"]]';\n    return {\n      shp: new Uint8Array(shpBuf),\n      shx: new Uint8Array(shxBuf),\n      dbf: dbfBytesU8,\n      prj: prj\n    };\n  }\n  function exportShapefile(){\n    var features = layoutToLineFeatures();\n    if(features.length === 0){ showUploadStatus('error', 'No lines to export. Generate a layout first.'); return; }\n    showUploadStatus('success', 'Building shapefile\u2026');\n    loadJSZip().then(function(){\n      var files = buildPolylineShapefile(features);\n      var zip = new window.JSZip();\n      zip.file('geopard_lines.shp', files.shp);\n      zip.file('geopard_lines.shx', files.shx);\n      zip.file('geopard_lines.dbf', files.dbf);\n      zip.file('geopard_lines.prj', files.prj);\n      zip.generateAsync({ type: 'blob', compression: 'STORE' }).then(function(blob){\n        triggerDownload(blob, 'geopard-guidance-lines.zip');\n        showUploadStatus('success', features.length + ' lines exported as zipped shapefile');\n      }).catch(function(err){\n        showUploadStatus('error', 'Shapefile zip failed: ' + (err.message || err));\n        try { console.error('[GPL export]', err); } catch(_){}\n      });\n    }).catch(function(){\n      showUploadStatus('error', 'Could not load zip library \u2014 check internet connection');\n    });\n  }\n  function showUploadStatus(state, msg){\n    var lbl = document.getElementById('gpl-upload-lbl');\n    var hint = document.getElementById('gpl-upload-hint');\n    if(!lbl) return;\n    lbl.classList.remove('is-error', 'is-success');\n    if(state === 'error') lbl.classList.add('is-error');\n    else if(state === 'success') lbl.classList.add('is-success');\n    if(hint) hint.textContent = msg;\n  }\n  \/\/ Apply ALL imported polygons from the uploaded file as ONE multi-part field.\n  \/\/ Real-world rule: if a file contains multiple polygons, they belong to the\n  \/\/ same operational field (e.g. parts split by a creek or a road). The\n  \/\/ machine works each part in turn with a transport leg between them.\n  \/\/ The iterator (\u2039 \/ \u203a) is no longer needed because there is exactly ONE\n  \/\/ field per upload \u2014 kept hidden.\n  \/\/ Topography follow needs real elevation data we don't have for\n  \/\/ uploaded fields (the demo synthesises terrain only for the 4 sample\n  \/\/ fields). Lock the option behind a \"PRO\" badge on uploaded fields\n  \/\/ and route the user to GeoPard sign-up when they click it.\n  function updateAdaptiveLock(){\n    var label = document.getElementById('gpl-ap-adaptive-label');\n    var lock = document.getElementById('gpl-ap-adaptive-lock');\n    if(!label ? true : !lock) return;\n    var isImported = currentField === 'custom' ? true : currentField === 'uploaded-lines';\n    label.classList.toggle('is-pro-locked', isImported);\n    lock.hidden = !isImported;\n    var radio = label.querySelector('input[type=\"radio\"]');\n    if(radio) radio.disabled = isImported;\n    \/\/ If user has 'adaptive' selected when they import, auto-switch\n    \/\/ to 'ab-straight' so the layout actually computes.\n    if(isImported ? current === 'adaptive' : false){\n      current = 'ab-straight';\n      var abRadio = document.querySelector('#gpl-approach input[value=\"ab-straight\"]');\n      if(abRadio){\n        abRadio.checked = true;\n        var labs2 = document.querySelectorAll('#gpl-approach label');\n        for(var li2=0; li2<labs2.length; li2++) labs2[li2].classList.remove('is-on');\n        if(abRadio.parentNode ? abRadio.parentNode.classList : false) abRadio.parentNode.classList.add('is-on');\n      }\n    }\n  }\n  function applyImportedField(){\n    if(!importedFields.length) return;\n    importedIdx = 0;\n    \/\/ Project all polygons using a SHARED origin so they sit in the same\n    \/\/ canvas space (preserves relative position between parts).\n    var allCoords = importedFields.map(function(p){ return p.coords; });\n    var partsPts = lngLatToCanvasMulti(allCoords);\n    if(!partsPts.length){ showUploadStatus('error', 'No polygons with \u22653 vertices found'); return; }\n    \/\/ Freeze protection \u2014 real-world shapefiles often have 100s to\n    \/\/ 1000s of vertices per ring (satellite-derived boundaries, GPS-\n    \/\/ recorded perimeter walks). Every geometry pass scales with N and\n    \/\/ gets multiplied by: 5 approaches \u00d7 axis sweep (12 angles) \u00d7\n    \/\/ buildSerpentine (O(passes\u00b2) for U-turn arc validation). At 500+\n    \/\/ verts on a 300 ha field the browser locks up. Simplify ALL\n    \/\/ imported rings to a tolerance proportional to the implement\n    \/\/ width \u2014 sub-wM\/4 vertex spacing is below the operator's\n    \/\/ visible resolution anyway. Read the current wM from the input\n    \/\/ since this runs before recompute().\n    var rawWmIn = parseFloat((document.getElementById('gpl-wm') || {}).value) || 18;\n    var wMIn = unitSystem === 'us' ? rawWmIn \/ M_TO_FT : rawWmIn;\n    if(wMIn < 6) wMIn = 18;\n    \/\/ Simplify tolerance wM\/4 (was wM\/2). wM\/2 was so aggressive it\n    \/\/ erased the natural reflex bends that define multi-arm fields\n    \/\/ (3-direction sample \u2192 1 block instead of 3), defeating the\n    \/\/ auto-blocks approach. wM\/4 keeps those bends while still\n    \/\/ reducing 100+ vertex inputs to ~30 verts \u2014 fast enough to\n    \/\/ dodge the freeze without losing structure.\n    var simpDropTol = wMIn * 0.25;      \/\/ merge vertices closer than wM\/4\n    var perpTol     = wMIn * 0.1;       \/\/ strip collinear within wM\/10\n    for(var spi=0; spi<partsPts.length; spi++){\n      var p = partsPts[spi];\n      var simp = simplifyPolygon(p, simpDropTol, perpTol);\n      if(simp ? simp.length >= 4 : false) partsPts[spi] = simp;\n    }\n    \/\/ Obstacle \/ hole detection (rule \u00a76 `obstacle_boundary`). A ring\n    \/\/ fully contained inside a larger ring is a hole, not a separate\n    \/\/ field. assignHoles re-parents them so r038-style fields (1 outer +\n    \/\/ 9 inner exclusion zones) install as ONE part with 9 holes instead\n    \/\/ of 10 disconnected \"fields\".\n    partsPts = assignHoles(partsPts);\n    \/\/ Hard guard on field-size \u00d7 resolution: if the estimated total\n    \/\/ pass count would exceed CAP (the threshold above which the\n    \/\/ synchronous Compare-All recompute starts feeling laggy), bump\n    \/\/ the implement width displayed to the user so the demo stays\n    \/\/ responsive. Real planners can handle thousands of passes; this\n    \/\/ is a lead-magnet tool, not the full GeoPard platform.\n    var totalArea = 0;\n    var maxSpan = 0;\n    for(var saI=0; saI<partsPts.length; saI++){\n      totalArea += polyArea(partsPts[saI]);\n      var minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;\n      for(var saV=0; saV<partsPts[saI].length; saV++){\n        var pt = partsPts[saI][saV];\n        if(pt.x < minX) minX = pt.x; if(pt.x > maxX) maxX = pt.x;\n        if(pt.y < minY) minY = pt.y; if(pt.y > maxY) maxY = pt.y;\n      }\n      var span = Math.max(maxX - minX, maxY - minY);\n      if(span > maxSpan) maxSpan = span;\n    }\n    var estPasses = maxSpan \/ wMIn;\n    var LAG_CAP_PASSES = 150;\n    if(estPasses > LAG_CAP_PASSES){\n      \/\/ Suggested width that would land at LAG_CAP_PASSES on the\n      \/\/ largest dimension. Show as a warning hint; don't force-change\n      \/\/ the input (operator should know what they're picking).\n      var suggestedW = maxSpan \/ LAG_CAP_PASSES;\n      if(unitSystem === 'us') suggestedW = suggestedW * M_TO_FT;\n      showUploadStatus('error', 'Large field (~' + (totalArea \/ 10000).toFixed(0) + ' ha, ' + Math.round(estPasses) + ' passes at current width). Increase implement width above ' + suggestedW.toFixed(0) + ' ' + (unitSystem === 'us' ? 'ft' : 'm') + ' for smooth playback.');\n    }\n    \/\/ Pick the largest part as the \"primary\" boundary for legacy single-poly\n    \/\/ call sites (PCA axis, terrain heatmap, shape-recommendation heuristic).\n    var largestIdx = 0, largestA = -Infinity;\n    for(var li=0; li<partsPts.length; li++){\n      var a = polyArea(partsPts[li]);\n      if(a > largestA){ largestA = a; largestIdx = li; }\n    }\n    var primary = partsPts[largestIdx];\n    FIELDS.custom = primary;\n    currentField = 'custom';\n    BOUNDARY = primary;\n    BOUNDARY_PARTS = partsPts;\n    userAxisDeg = null;\n    headingStrategy = 'longest';\n    \/\/ Remember for the Imported chip (boundary upload path \u2014 no lines).\n    IMPORTED_BOUNDARY = BOUNDARY;\n    IMPORTED_BOUNDARY_PARTS = BOUNDARY_PARTS;\n    IMPORTED_LINES = null;\n    IMPORTED_NAME = importedFields[0].name || 'boundary';\n    activateImportedChip(IMPORTED_NAME);\n    var navEl = document.getElementById('gpl-import-nav');\n    if(navEl) navEl.hidden = true;  \/\/ iterator no longer used\n    var partLabel = partsPts.length > 1 ? (partsPts.length + ' parts') : (primary.length + ' vertices');\n    showUploadStatus('success', (importedFields[0].name || 'boundary') + ' \u00b7 ' + partLabel);\n    \/\/ Reset pan\/zoom so the new field fits fresh (see applyCombinedGeoImport\n    \/\/ \u2014 leftover transform from a prior import hid the 2nd field on mobile).\n    view.zoom = 1; view.panX = 0; view.panY = 0;\n    updateAdaptiveLock();\n    updatePlanViewToggle();\n    recompute();\n  }\n  \/\/ Uploaded existing guidance lines (LineString[]). When the user uploads\n  \/\/ a guidance-line file (instead of a boundary), each line gets projected\n  \/\/ to canvas coords here. Set by handleParsedLines, cleared when the user\n  \/\/ picks a built-in sample field. recompute reads this to compute existing\n  \/\/ metrics + draw renders these as a blue semi-transparent underlay so the\n  \/\/ user can compare their existing plan against the proposed plan.\n  var UPLOADED_LINES = null;\n  \/\/ Saved import for the \"Imported\" chip in the top field-picker. Stays\n  \/\/ populated after the user clicks across to a sample field so the chip\n  \/\/ can restore the imported state without re-uploading the file.\n  var IMPORTED_BOUNDARY = null;\n  var IMPORTED_BOUNDARY_PARTS = null;\n  var IMPORTED_LINES = null;  \/\/ null when the import was a boundary, not lines\n  var IMPORTED_NAME = null;\n  \/\/ Raw lng\/lat cache \u2014 preserved so a SECOND import (lines after\n  \/\/ boundary, or boundary after lines) can re-project everything with\n  \/\/ a shared origin and have the two layers align in canvas space.\n  \/\/ Without this, the second import either over-writes the first or\n  \/\/ lands in a different coordinate frame.\n  var IMPORTED_BOUNDARY_GEO = null;  \/\/ Array<Array<[lng,lat]>>  rings\n  var IMPORTED_LINES_GEO = null;     \/\/ Array<Array<[lng,lat]>>  polylines\n  var IMPORTED_LINES_DRIVEN = null;  \/\/ Array<boolean>  per-line driven flag\n  function activateImportedChip(label){\n    var chip = document.getElementById('gpl-fld-imported');\n    if(!chip) return;\n    chip.hidden = false;\n    if(label) chip.title = 'Restore imported field: ' + label;\n    var picker = document.querySelectorAll('#gpl-field-picker .gpl-fld');\n    for(var pp=0; pp<picker.length; pp++) picker[pp].classList.remove('is-on');\n    chip.classList.add('is-on');\n  }\n  \/\/ Convert uploaded GUIDANCE LINES (LineString array, each line is an\n  \/\/ array of [lon, lat]) into the same canvas coord space as the built-in\n  \/\/ fields, then derive a field boundary via convex hull of the line\n  \/\/ endpoints. The resulting boundary becomes BOUNDARY \/ BOUNDARY_PARTS so\n  \/\/ every existing path-planning algorithm runs against it unchanged.\n  function handleParsedLines(lines, filename){\n    if(!lines ? true : lines.length === 0){\n      showUploadStatus('error', 'No guidance lines found in ' + filename);\n      return;\n    }\n    \/\/ Shared projection origin so all lines share the same canvas space.\n    var oLat = 0, oLng = 0, total = 0;\n    for(var i=0; i<lines.length; i++){\n      for(var j=0; j<lines[i].length; j++){\n        oLng += lines[i][j][0];\n        oLat += lines[i][j][1];\n        total++;\n      }\n    }\n    if(total === 0){\n      showUploadStatus('error', 'Empty geometry in ' + filename);\n      return;\n    }\n    oLat \/= total; oLng \/= total;\n    var cosLat = Math.cos(oLat * Math.PI \/ 180);\n    var projectedLines = [];\n    var allPts = [];\n    for(var l=0; l<lines.length; l++){\n      var line = [];\n      \/\/ Preserve the .driven tag from extractLinesGeoJSON so the\n      \/\/ boundary derivation can still hull every endpoint while\n      \/\/ metrics + playback only count truly driven passes.\n      line.driven = lines[l].driven !== false;\n      for(var v=0; v<lines[l].length; v++){\n        var lng = lines[l][v][0], lat = lines[l][v][1];\n        var mx = (lng - oLng) * 111320 * cosLat;\n        var my = -(lat - oLat) * 111320;\n        line.push({ x: mx, y: my });\n        allPts.push({ x: mx, y: my });\n      }\n      projectedLines.push(line);\n    }\n    if(allPts.length < 3){\n      showUploadStatus('error', 'Not enough points to derive a boundary from ' + filename);\n      return;\n    }\n    \/\/ Concave hull of line endpoints \u2192 field boundary. The concave\n    \/\/ algorithm uses the two endpoints of each line (start + end), orders\n    \/\/ them by perpendicular position, and traces the headland-edge polygon\n    \/\/ \u2014 which is much closer to the real field shape than a convex hull\n    \/\/ (convex would round off L-shaped or trapezoidal fields). Falls\n    \/\/ back to convex hull if the lines aren't parallel enough to produce\n    \/\/ a non-self-intersecting polygon.\n    var hull = concaveHullFromLines(projectedLines, allPts);\n    if(hull.length < 3){\n      showUploadStatus('error', 'Could not derive a boundary from ' + filename);\n      return;\n    }\n    \/\/ Centre the layout at (380, 290) like the other imports do.\n    var minX=Infinity, maxX=-Infinity, minY=Infinity, maxY=-Infinity;\n    for(var hi=0; hi<hull.length; hi++){\n      if(hull[hi].x < minX) minX = hull[hi].x;\n      if(hull[hi].x > maxX) maxX = hull[hi].x;\n      if(hull[hi].y < minY) minY = hull[hi].y;\n      if(hull[hi].y > maxY) maxY = hull[hi].y;\n    }\n    var dx = 380 - (minX + maxX) * 0.5;\n    var dy = 290 - (minY + maxY) * 0.5;\n    var shiftedHull = hull.map(function(p){ return { x: p.x + dx, y: p.y + dy }; });\n    var shiftedLines = projectedLines.map(function(ln){\n      var out = ln.map(function(p){ return { x: p.x + dx, y: p.y + dy }; });\n      out.driven = ln.driven !== false;\n      return out;\n    });\n    \/\/ Install\n    BOUNDARY = shiftedHull;\n    BOUNDARY_PARTS = [shiftedHull];\n    UPLOADED_LINES = shiftedLines;\n    currentField = 'uploaded-lines';\n    userAxisDeg = null;\n    \/\/ Remember for the Imported chip (so the user can switch away to a\n    \/\/ sample field and back without re-uploading).\n    IMPORTED_BOUNDARY = BOUNDARY;\n    IMPORTED_BOUNDARY_PARTS = BOUNDARY_PARTS;\n    IMPORTED_LINES = UPLOADED_LINES;\n    IMPORTED_NAME = filename;\n    activateImportedChip(filename);\n    \/\/ Auto-detect implement width + headland-ring count from the uploaded\n    \/\/ lines so the user sees the proposed plan with parameters that match\n    \/\/ their actual operation \u2014 no manual tweaking needed. Each detection\n    \/\/ updates its respective input AND triggers updateHeadlandLabel so the\n    \/\/ slider readout stays in sync. Falls back silently when detection\n    \/\/ doesn't have enough lines (existing input values stick).\n    var autoBits = [];\n    var detectedWm = detectImplementWidthFromLines(shiftedLines);\n    if(detectedWm){\n      var wmEl = document.getElementById('gpl-wm');\n      if(wmEl){\n        var showVal = unitSystem === 'us' ? detectedWm * M_TO_FT : detectedWm;\n        \/\/ Round to nearest WHOLE unit (35.9 \u2192 36, 12.1 \u2192 12, 11.9 \u2192 12).\n        \/\/ Median-of-smallest-third detection has \u00b15 % noise from GPS jitter\n        \/\/ and feature parsing; sub-unit precision implies false accuracy.\n        \/\/ Snap further when very close to a standard width (12, 18, 24,\n        \/\/ 27, 30, 36 m \/ 30, 40, 60, 90, 120 ft) so the user sees the\n        \/\/ canonical number, not 36 instead of 35.\n        var rounded = Math.round(showVal);\n        var standards = unitSystem === 'us'\n          ? [20, 30, 40, 45, 60, 80, 90, 120]\n          : [6, 9, 12, 15, 18, 24, 27, 30, 36, 40, 45];\n        var snapTol = unitSystem === 'us' ? 2 : 1;\n        for(var snpi=0; snpi<standards.length; snpi++){\n          if(Math.abs(showVal - standards[snpi]) <= snapTol){ rounded = standards[snpi]; break; }\n        }\n        wmEl.value = String(rounded);\n        \/\/ Also reflect the snapped width back into the metres value so\n        \/\/ downstream fmtWidth + analytics use the rounded number.\n        detectedWm = unitSystem === 'us' ? rounded \/ M_TO_FT : rounded;\n        autoBits.push(fmtWidth(detectedWm) + ' width');\n      }\n    }\n    var ringCount = countInnerHeadlandRings(shiftedLines);\n    var hlEl = document.getElementById('gpl-hl-mult');\n    if(hlEl){\n      hlEl.value = String(Math.min(4, ringCount));\n      autoBits.push(ringCount + ' headland ring' + (ringCount === 1 ? '' : 's'));\n    }\n    if(typeof updateHeadlandLabel === 'function') updateHeadlandLabel();\n    var statusMsg = filename + ' \u00b7 ' + lines.length + ' line' + (lines.length === 1 ? '' : 's');\n    if(autoBits.length > 0) statusMsg += ' \u00b7 auto ' + autoBits.join(' + ');\n    showUploadStatus('success', statusMsg);\n    updateAdaptiveLock();\n    updatePlanViewToggle();\n    recompute();\n  }\n  \/\/ Unified geo-import path. Accepts raw lng\/lat data for the boundary\n  \/\/ (polygon rings) AND the lines (polylines), then projects everything\n  \/\/ into canvas space with a SHARED origin so the two layers align even\n  \/\/ when imported as separate files (boundary first, lines later, or\n  \/\/ vice versa \u2014 reported 2026-06-03). Either argument may be null.\n  function applyCombinedGeoImport(boundaryGeo, linesGeo, linesDriven, name){\n    var oLng = 0, oLat = 0, total = 0;\n    function tallyPts(coordsList){\n      if(!coordsList) return;\n      for(var r=0; r<coordsList.length; r++){\n        var ring = coordsList[r];\n        for(var k=0; k<ring.length; k++){ oLng += ring[k][0]; oLat += ring[k][1]; total++; }\n      }\n    }\n    tallyPts(boundaryGeo);\n    tallyPts(linesGeo);\n    if(total === 0){\n      showUploadStatus('error', 'No usable geometry in ' + name);\n      return;\n    }\n    oLat \/= total; oLng \/= total;\n    var cosLat = Math.cos(oLat * Math.PI \/ 180);\n    \/\/ Project boundary rings (raw metres around origin).\n    var projBoundary = null;\n    if(boundaryGeo ? boundaryGeo.length > 0 : false){\n      projBoundary = [];\n      for(var b=0; b<boundaryGeo.length; b++){\n        var ring = [];\n        for(var bv=0; bv<boundaryGeo[b].length; bv++){\n          var p = boundaryGeo[b][bv];\n          ring.push({ x: (p[0] - oLng) * 111320 * cosLat, y: -(p[1] - oLat) * 111320 });\n        }\n        \/\/ Strip trailing duplicate vertex (common in GeoJSON polygon rings).\n        if(ring.length > 1){\n          var f = ring[0], l = ring[ring.length - 1];\n          if(Math.abs(l.x - f.x) < 0.01 ? Math.abs(l.y - f.y) < 0.01 : false) ring.pop();\n        }\n        if(ring.length >= 3) projBoundary.push(ring);\n      }\n      if(projBoundary.length === 0) projBoundary = null;\n    }\n    \/\/ Project lines (raw metres around origin).\n    var projLines = null;\n    if(linesGeo ? linesGeo.length > 0 : false){\n      projLines = [];\n      for(var li=0; li<linesGeo.length; li++){\n        var ln = [];\n        for(var lv=0; lv<linesGeo[li].length; lv++){\n          var lp = linesGeo[li][lv];\n          ln.push({ x: (lp[0] - oLng) * 111320 * cosLat, y: -(lp[1] - oLat) * 111320 });\n        }\n        ln.driven = linesDriven ? (linesDriven[li] !== false) : true;\n        if(ln.length >= 2) projLines.push(ln);\n      }\n      if(projLines.length === 0) projLines = null;\n    }\n    \/\/ No explicit boundary? Derive one from the line endpoints (concave hull).\n    if(!projBoundary ? projLines : false){\n      var allPts = [];\n      for(var hi=0; hi<projLines.length; hi++){\n        for(var hv=0; hv<projLines[hi].length; hv++){\n          allPts.push({ x: projLines[hi][hv].x, y: projLines[hi][hv].y });\n        }\n      }\n      \/\/ Derive boundary from line endpoints. When the file has a\n      \/\/ closed-loop headland trace (operator's wheel-track centerline),\n      \/\/ expand that loop OUTWARD by wM\/2 so the boundary lands at the\n      \/\/ true field edge instead of the wheel-track line. Use the\n      \/\/ auto-detected wM (from line spacing) if available; fall back to\n      \/\/ the slider's value otherwise.\n      var detectedWmFirst = detectImplementWidthFromLines(projLines);\n      var rawWmIn0 = parseFloat((document.getElementById('gpl-wm') || {}).value) || 18;\n      var wMIn0 = unitSystem === 'us' ? rawWmIn0 \/ M_TO_FT : rawWmIn0;\n      var wMForExpand = detectedWmFirst ? detectedWmFirst : wMIn0;\n      var hull = concaveHullFromLines(projLines, allPts, wMForExpand * 0.5);\n      if(hull.length >= 3) projBoundary = [hull];\n    }\n    if(!projBoundary){\n      showUploadStatus('error', 'Could not derive a boundary from ' + name);\n      return;\n    }\n    \/\/ Compute global bbox + a single shift so everything lands centred-ish.\n    var minX=Infinity, maxX=-Infinity, minY=Infinity, maxY=-Infinity;\n    function bumpBB(arr){\n      for(var s=0; s<arr.length; s++){\n        if(arr[s].x < minX) minX = arr[s].x;\n        if(arr[s].x > maxX) maxX = arr[s].x;\n        if(arr[s].y < minY) minY = arr[s].y;\n        if(arr[s].y > maxY) maxY = arr[s].y;\n      }\n    }\n    for(var bi=0; bi<projBoundary.length; bi++) bumpBB(projBoundary[bi]);\n    if(projLines) for(var lj=0; lj<projLines.length; lj++) bumpBB(projLines[lj]);\n    var cx = 380 - (minX + maxX) * 0.5;\n    var cy = 290 - (minY + maxY) * 0.5;\n    function shift(arr){ for(var s=0; s<arr.length; s++){ arr[s].x += cx; arr[s].y += cy; } }\n    for(var bsi=0; bsi<projBoundary.length; bsi++) shift(projBoundary[bsi]);\n    if(projLines) for(var lsi=0; lsi<projLines.length; lsi++) shift(projLines[lsi]);\n    \/\/ No simplification on import \u2014 preserve every vertex of the user's\n    \/\/ imported boundary + lines so the displayed geometry matches their\n    \/\/ source file exactly (QGIS-equivalent rendering). Rule \u00a720 (added\n    \/\/ 2026-06-03 \u2014 \"lets not simplify anythin on import and export\").\n    \/\/ The previous wM-based simplify collapsed dense boundaries on\n    \/\/ lv-class fields, shrinking them out of alignment with the lines.\n    \/\/ Obstacle \/ hole detection \u2014 re-parent contained rings as holes\n    \/\/ (rule \u00a76 `obstacle_boundary`) so an Rx-style multi-ring boundary\n    \/\/ with interior exclusion zones installs as ONE part + holes, not\n    \/\/ as many disconnected fields.\n    projBoundary = assignHoles(projBoundary);\n    \/\/ Largest part = primary boundary (legacy single-poly call sites).\n    var largestI = 0, largestA = -Infinity;\n    for(var lpi=0; lpi<projBoundary.length; lpi++){\n      var a = polyArea(projBoundary[lpi]);\n      if(a > largestA){ largestA = a; largestI = lpi; }\n    }\n    var primary = projBoundary[largestI];\n    FIELDS.custom = primary;\n    BOUNDARY = primary;\n    BOUNDARY_PARTS = projBoundary;\n    \/\/ UPLOADED_LINES keeps EVERY imported line (including closed-loop\n    \/\/ headland traces) so the dashed-blue canvas overlay matches what\n    \/\/ QGIS \/ John Deere Ops would render \u2014 visible perimeter trace +\n    \/\/ body passes. The headland-ring loop was filtered out in an\n    \/\/ earlier revision but the user's QGIS reference clearly shows the\n    \/\/ loop as part of the imported features (reported 2026-06-03).\n    \/\/ `bodyPassesOnly` still runs inside buildUploadedLayout +\n    \/\/ Compare All so playback \/ metrics don't double-count the loop\n    \/\/ as a driven body pass.\n    UPLOADED_LINES = (projLines ? projLines.length > 0 : false) ? projLines : null;\n    currentField = UPLOADED_LINES ? 'uploaded-lines' : 'custom';\n    userAxisDeg = null;\n    userAxisAnchor = null;  \/\/ new import \u2192 drop any prior GPS anchor\n    headingStrategy = 'longest';\n    IMPORTED_BOUNDARY = BOUNDARY;\n    IMPORTED_BOUNDARY_PARTS = BOUNDARY_PARTS;\n    IMPORTED_LINES = UPLOADED_LINES;\n    IMPORTED_BOUNDARY_GEO = boundaryGeo;\n    IMPORTED_LINES_GEO = linesGeo;\n    IMPORTED_LINES_DRIVEN = linesDriven;\n    IMPORTED_NAME = name;\n    \/\/ Capture origin for GIS export.\n    EXPORT_ORIGIN_LNG = oLng;\n    EXPORT_ORIGIN_LAT = oLat;\n    EXPORT_COSLAT = cosLat;\n    EXPORT_SHIFT_X = cx;\n    EXPORT_SHIFT_Y = cy;\n    activateImportedChip(name);\n    var navEl0 = document.getElementById('gpl-import-nav');\n    if(navEl0) navEl0.hidden = true;\n    \/\/ Auto-detect implement width + headland rings when lines present.\n    var autoBits = [];\n    if(projLines){\n      var detectedWm = detectImplementWidthFromLines(projLines);\n      if(detectedWm){\n        var wmEl = document.getElementById('gpl-wm');\n        if(wmEl){\n          var showVal = unitSystem === 'us' ? detectedWm * M_TO_FT : detectedWm;\n          var rounded = Math.round(showVal);\n          var standards = unitSystem === 'us' ? [20,30,40,45,60,80,90,120] : [6,9,12,15,18,24,27,30,36,40,45];\n          var snapTol = unitSystem === 'us' ? 2 : 1;\n          for(var sti=0; sti<standards.length; sti++){\n            if(Math.abs(showVal - standards[sti]) <= snapTol){ rounded = standards[sti]; break; }\n          }\n          wmEl.value = String(rounded);\n          detectedWm = unitSystem === 'us' ? rounded \/ M_TO_FT : rounded;\n          autoBits.push(fmtWidth(detectedWm) + ' width');\n        }\n      }\n      \/\/ Headland-ring count auto-detected from closed loops in the\n      \/\/ import. Fires for both lines-only and lines+boundary imports\n      \/\/ \u2014 operator-driven headland traces (closed loops) are equally\n      \/\/ meaningful in both cases (reported 2026-06-04 \u2014 \"import all\n      \/\/ lines, headland too if there are headland lines\").\n      var ringCount = countInnerHeadlandRings(projLines);\n      if(ringCount > 0){\n        var hlEl = document.getElementById('gpl-hl-mult');\n        if(hlEl){\n          hlEl.value = String(Math.min(4, ringCount));\n          autoBits.push(ringCount + ' headland ring' + (ringCount === 1 ? '' : 's'));\n        }\n        if(typeof updateHeadlandLabel === 'function') updateHeadlandLabel();\n      }\n    }\n    \/\/ Lag guard for large boundary imports. applyImportedField has one;\n    \/\/ the combined path (which lone-polygon zips route through) did not \u2014\n    \/\/ a 207 ha \/ 9-part field (41.22\u2026) produced ~290 body passes at the\n    \/\/ 18 m default, and the synchronous recompute + Compare-All + axis\n    \/\/ sweep froze the browser for ~100 s, so the field never appeared\n    \/\/ (\"not shown in UI\", reported 2026-06-10). Estimate total passes at\n    \/\/ the current width from each part's perpendicular span; if it would\n    \/\/ lag, raise the implement width to the smallest standard that keeps\n    \/\/ the count near the target, and tell the user (they can dial it back\n    \/\/ for a narrower implement \u2014 it just won't be as snappy).\n    (function lagGuard(){\n      var wmElG = document.getElementById('gpl-wm');\n      if(!wmElG) return;\n      var rawWmG = parseFloat(wmElG.value) || 18;\n      var wMG = unitSystem === 'us' ? rawWmG \/ M_TO_FT : rawWmG;\n      if(wMG <= 0) return;\n      var sumPerp = 0;\n      for(var lg=0; lg<projBoundary.length; lg++){\n        var prt = projBoundary[lg];\n        var axg = fieldAxis(prt);\n        var pmin = Infinity, pmax = -Infinity;\n        for(var pv=0; pv<prt.length; pv++){\n          var perp = -prt[pv].x * axg.uy + prt[pv].y * axg.ux;\n          if(perp < pmin) pmin = perp;\n          if(perp > pmax) pmax = perp;\n        }\n        sumPerp += (pmax - pmin);\n      }\n      var TARGET_PASSES = 140;\n      var passEst = sumPerp \/ wMG;\n      if(passEst <= 170) return;  \/\/ current width is fine\n      var needWmM = sumPerp \/ TARGET_PASSES;  \/\/ metric width needed\n      var needWmDisp = unitSystem === 'us' ? needWmM * M_TO_FT : needWmM;\n      var stds = unitSystem === 'us' ? [20,30,40,45,60,80,90,120] : [12,15,18,24,27,30,36,40,45,54,60];\n      var pickDisp = stds[stds.length - 1];\n      for(var si=0; si<stds.length; si++){ if(stds[si] >= needWmDisp){ pickDisp = stds[si]; break; } }\n      \/\/ Only raise, never lower the user's width.\n      if(pickDisp > rawWmG){\n        wmElG.value = String(pickDisp);\n        if(typeof updateHeadlandLabel === 'function') updateHeadlandLabel();\n        autoBits.push('width raised to ' + fmtWidth(unitSystem === 'us' ? pickDisp \/ M_TO_FT : pickDisp) + ' for smooth playback (large field \u2014 dial down if your implement is narrower)');\n      }\n    })();\n    var partLabel = projBoundary.length > 1 ? (projBoundary.length + ' parts') : (primary.length + ' vertices');\n    var combo = [];\n    if(boundaryGeo){\n      \/\/ Imported polygon \u2014 the AUTHORITATIVE boundary. All planners,\n      \/\/ playback, and metrics run against it (rule \u00a76 \u2014 machine never\n      \/\/ leaves the boundary). Surface \"imported\" so the user can tell\n      \/\/ the simulator is honouring their file vs deriving its own hull.\n      combo.push('imported boundary \u00b7 ' + partLabel);\n    } else if(projLines){\n      \/\/ No real boundary provided \u2192 concaveHullFromLines derived one\n      \/\/ from line endpoints. Flag this clearly so the user knows the\n      \/\/ outline is approximate (upload an actual boundary file to use\n      \/\/ the authoritative one instead).\n      combo.push('derived boundary \u00b7 ' + partLabel);\n    }\n    if(projLines) combo.push(projLines.length + ' line' + (projLines.length === 1 ? '' : 's'));\n    var statusMsg = name + ' \u00b7 ' + combo.join(' + ');\n    if(autoBits.length > 0) statusMsg += ' \u00b7 auto ' + autoBits.join(' + ');\n    showUploadStatus('success', statusMsg);\n    \/\/ Reset the pan\/zoom transform so the NEW field fits the canvas from\n    \/\/ scratch. Without this, a leftover pan\/zoom from inspecting the\n    \/\/ previous import (trivial to trigger on mobile \u2014 any touch-pan\n    \/\/ leaves residual offset) is applied on top of the new field's\n    \/\/ fit, pushing it off-screen \u2192 \"imported 2nd field, nothing shows\"\n    \/\/ (reported 2026-06-14). Field-button switches already reset the\n    \/\/ view; the import path must too.\n    view.zoom = 1; view.panX = 0; view.panY = 0;\n    updateAdaptiveLock();\n    updatePlanViewToggle();\n    recompute();\n  }\n  \/\/ Convert raw file \u2192 list of polygons \u2192 install + apply first one.\n  function handleParsedPolygons(polys, filename){\n    if(!polys ? true : polys.length === 0){\n      showUploadStatus('error', 'No polygons found in ' + filename);\n      return;\n    }\n    importedFields = polys;\n    importedIdx = 0;\n    showUploadStatus('success', filename + ' \u00b7 ' + polys.length + (polys.length > 1 ? ' parts (one field)' : ' boundary'));\n    runWithLoader('Reading your field\u2026', applyImportedField);\n  }\n  \/\/ Detect format by extension + content, dispatch to parser. Async because\n  \/\/ .zip needs shpjs which loads lazily from CDN.\n  \/\/ Route an import (polys + lines + name) through applyCombinedGeoImport.\n  \/\/ Preserves prior-import state when the new file only has ONE of the\n  \/\/ two layers \u2014 so the user can drop a boundary, THEN drop lines, and\n  \/\/ the simulator keeps both (rather than wiping one with the next).\n  function dispatchImported(polys, lines, name){\n    var polysRaw = polys ? polys.map(function(p){ return p.coords; }).filter(function(c){ return c ? c.length >= 3 : false; }) : [];\n    var linesRaw = lines || [];\n    var linesDriven = linesRaw.map(function(l){ return l.driven !== false; });\n    var bGeo = polysRaw.length > 0 ? polysRaw : IMPORTED_BOUNDARY_GEO;\n    var lGeo = linesRaw.length > 0 ? linesRaw : IMPORTED_LINES_GEO;\n    var lDriven = linesRaw.length > 0 ? linesDriven : IMPORTED_LINES_DRIVEN;\n    if(!bGeo ? !lGeo : false){\n      showUploadStatus('error', 'No boundary or guidance lines found in ' + name);\n      return;\n    }\n    runWithLoader('Reading your field\u2026', function(){\n      applyCombinedGeoImport(bGeo, lGeo, lDriven, name);\n    });\n  }\n  \/\/ Parse a single file and invoke cb(polys, lines, errOrNull). No\n  \/\/ side-effects beyond loading shpjs lazily. Used by importFiles to\n  \/\/ batch multiple files together before dispatching.\n  function parseFileToShapes(file, cb){\n    var name = (file.name || '').toLowerCase();\n    function finish(polys, lines, err){ try { cb(polys, lines, err); } catch(_){} }\n    if(name.indexOf('.zip') >= 0 ? true : name.indexOf('.shp') >= 0){\n      var reader = new FileReader();\n      reader.onload = function(){\n        loadShpjs().then(function(){\n          try {\n            var p = window.shp(reader.result);\n            (p ? (p.then ? p : Promise.resolve(p)) : Promise.resolve(p)).then(function(res){\n              var collections = Array.isArray(res) ? res : [res];\n              var aggStats = { pointCount: 0, polyCount: 0, lineCount: 0 };\n              for(var cc=0; cc<collections.length; cc++){\n                var s = classifyImport(collections[cc]);\n                aggStats.pointCount += s.pointCount;\n                aggStats.polyCount += s.polyCount;\n                aggStats.lineCount += s.lineCount;\n              }\n              var rej = explainImportRejection(aggStats, file.name);\n              if(rej){ finish(null, null, rej); return; }\n              var polys = [], lines = [];\n              for(var c=0; c<collections.length; c++){\n                polys = polys.concat(extractAllPolygonsGeoJSON(collections[c]));\n                lines = lines.concat(extractLinesGeoJSON(collections[c]));\n              }\n              finish(polys, lines, null);\n            }).catch(function(err){\n              finish(null, null, 'Shapefile parse failed: ' + (err.message || err));\n            });\n          } catch(err){\n            finish(null, null, 'Shapefile parse failed: ' + (err.message || err));\n          }\n        }).catch(function(err){\n          finish(null, null, err.message || 'Could not load shapefile parser');\n        });\n      };\n      reader.onerror = function(){ finish(null, null, 'Could not read file'); };\n      reader.readAsArrayBuffer(file);\n    } else {\n      var reader2 = new FileReader();\n      reader2.onload = function(){\n        var text = reader2.result;\n        var polys = [], lines = [];\n        if(name.indexOf('.kml') >= 0){\n          polys = extractAllPolygonsKML(text);\n        } else {\n          try {\n            var obj = JSON.parse(text);\n            var stats = classifyImport(obj);\n            var rej = explainImportRejection(stats, file.name);\n            if(rej){ finish(null, null, rej); return; }\n            polys = extractAllPolygonsGeoJSON(obj);\n            lines = extractLinesGeoJSON(obj);\n          } catch(_){ polys = extractAllPolygonsKML(text); }\n        }\n        finish(polys, lines, null);\n      };\n      reader2.onerror = function(){ finish(null, null, 'Could not read file'); };\n      reader2.readAsText(file);\n    }\n  }\n  \/\/ Batch multiple files into ONE dispatch so a poly + lines pair are\n  \/\/ installed in a single applyCombinedGeoImport call. Previously each\n  \/\/ file ran its own dispatch sequentially \u2192 the lines file (faster\n  \/\/ to parse) would land first, derive a hull as the \"simulated\"\n  \/\/ boundary, then the boundary file would land second and replace the\n  \/\/ hull. The user saw the hull flash by + (when WP cache + canvas\n  \/\/ residue lined up wrong) thought the hull was still showing\n  \/\/ (reported 2026-06-03: \"in case both lines and boundary for the\n  \/\/ same field imported \u2014 don't show simulated boundary\").\n  function importFiles(files){\n    if(!files ? true : files.length === 0) return;\n    showUploadStatus('default', 'Reading ' + files.length + ' file' + (files.length === 1 ? '' : 's') + '\u2026');\n    var pending = files.length;\n    var allPolys = [];\n    var allLines = [];\n    var fileNames = [];\n    var firstError = null;\n    for(var fi=0; fi<files.length; fi++){\n      (function(file){\n        parseFileToShapes(file, function(polys, lines, err){\n          fileNames.push(file.name);\n          if(err){ if(!firstError) firstError = err; }\n          else {\n            if(polys) allPolys = allPolys.concat(polys);\n            if(lines) allLines = allLines.concat(lines);\n          }\n          if(--pending === 0){\n            if(firstError){ showUploadStatus('error', firstError); return; }\n            dispatchImported(allPolys, allLines, fileNames.join(' + '));\n          }\n        });\n      })(files[fi]);\n    }\n  }\n  function importFile(file){ importFiles([file]); }\n  var fileInputEl = document.getElementById('gpl-upload');\n  if(fileInputEl){\n    fileInputEl.addEventListener('change', function(){\n      var files = this.files ? this.files : null;\n      if(!files ? true : files.length === 0) return;\n      \/\/ importFiles batches ALL selected files into a single dispatch so\n      \/\/ a boundary + lines pair installs together (no transient hull).\n      importFiles(files);\n      try { this.value = ''; } catch(_){}\n    });\n  }\n  \/\/ Iterator buttons \u2014 step through importedFields\n  \/\/ Multi-polygon files now collapse to ONE field with N parts, so the\n  \/\/ \u2039 \/ \u203a iterator buttons are no longer needed. Kept in the DOM (hidden via\n  \/\/ applyImportedField \u2192 nav.hidden = true) so the markup stays compatible,\n  \/\/ but no click handlers are attached.\n  \/\/ Field picker \u2014 swap BOUNDARY in place, auto-pick the recommended approach,\n  \/\/ then re-fit \/ re-render. Resets the AB-line override so each new field\n  \/\/ starts from its PCA-derived natural axis.\n  var fldBtns = document.querySelectorAll('#gpl-field-picker .gpl-fld');\n  for(var fb=0; fb<fldBtns.length; fb++){\n    fldBtns[fb].addEventListener('click', function(){\n      var key = this.getAttribute('data-field');\n      \/\/ Imported chip \u2014 restore the saved upload state. Independent path\n      \/\/ from sample-field selection so we don't go through FIELDS[key].\n      if(key === 'imported'){\n        if(!IMPORTED_BOUNDARY) return;\n        currentField = IMPORTED_LINES ? 'uploaded-lines' : 'custom';\n        BOUNDARY = IMPORTED_BOUNDARY;\n        BOUNDARY_PARTS = IMPORTED_BOUNDARY_PARTS;\n        UPLOADED_LINES = IMPORTED_LINES;\n        userAxisDeg = null;\n        abTool.active = false; abTool.p1 = null; abTool.p2 = null;\n        var abBtnImp = document.getElementById('gpl-tool-ab');\n        var abHintImp = document.getElementById('gpl-ab-hint');\n        if(abBtnImp) abBtnImp.classList.remove('is-on');\n        if(abHintImp) abHintImp.classList.remove('is-on');\n        var autoBtnImp = document.getElementById('gpl-ab-auto');\n        if(autoBtnImp) autoBtnImp.classList.add('is-auto');\n        for(var fi0=0; fi0<fldBtns.length; fi0++) fldBtns[fi0].classList.remove('is-on');\n        this.classList.add('is-on');\n        showUploadStatus('success', IMPORTED_NAME + ' \u00b7 restored');\n        updateAdaptiveLock();\n    updatePlanViewToggle();\n        recompute();\n        return;\n      }\n      if(!key ? true : !FIELDS[key]) return;\n      currentField = key;\n      BOUNDARY = FIELDS[key];\n      BOUNDARY_PARTS = FIELD_PARTS[key] ? FIELD_PARTS[key] : [BOUNDARY];\n      UPLOADED_LINES = null;  \/\/ clear any prior guidance-lines upload\n      userAxisDeg = null;  \/\/ back to Auto on every field change\n      userAxisAnchor = null;  \/\/ GPS-coords anchor is field-specific too\n      headingStrategy = \"longest\";\n      BLOCK_AXIS_OVERRIDES = {};  \/\/ per-block overrides scoped to one field\n      abTool.active = false; abTool.p1 = null; abTool.p2 = null;\n      var abBtnSw = document.getElementById('gpl-tool-ab');\n      var abHintSw = document.getElementById('gpl-ab-hint');\n      if(abBtnSw) abBtnSw.classList.remove('is-on');\n      if(abHintSw) abHintSw.classList.remove('is-on');\n      var autoBtn = document.getElementById('gpl-ab-auto');\n      if(autoBtn) autoBtn.classList.add('is-auto');\n      for(var fi=0; fi<fldBtns.length; fi++) fldBtns[fi].classList.remove('is-on');\n      this.classList.add('is-on');\n      \/\/ Reset the upload card status so it doesn't keep the success-state badge\n      showUploadStatus('default', 'GeoJSON \/ KML \/ Shapefile zip');\n      if(fileInputEl) fileInputEl.value = '';\n      var nav2 = document.getElementById('gpl-import-nav');\n      if(nav2) nav2.hidden = true;\n      importedFields = [];\n      importedIdx = 0;\n      \/\/ Pre-pick with the shape-based heuristic for instant feedback; the\n      \/\/ first recompute() below will then re-evaluate with real metrics\n      \/\/ (coverage + turns + fuel) and may flip to a better approach.\n      var pick = recommendApproach(BOUNDARY).pick;\n      current = pick;\n      var apRadios = document.querySelectorAll('#gpl-approach input[type=radio]');\n      var apLabels = document.querySelectorAll('#gpl-approach label');\n      for(var ar=0; ar<apRadios.length; ar++){\n        var match = apRadios[ar].value === pick;\n        apRadios[ar].checked = match;\n        if(apLabels[ar]) apLabels[ar].classList.toggle('is-on', match);\n      }\n      updateAdaptiveLock();\n    updatePlanViewToggle();\n      \/\/ Mark the upcoming recompute as eligible for auto-best-pick. The\n      \/\/ deferred Compare All fill will then call autoPickBestApproach\n      \/\/ ONCE with full apMetrics. Cleared automatically by the swap.\n      pendingAutoPick = true;\n      recompute();\n      autoPickBestApproach();\n    });\n  }\n  \/\/ AB-direction controls\n  var abSliderEl2 = document.getElementById('gpl-ab-deg');\n  var abAutoBtnEl = document.getElementById('gpl-ab-auto');\n  if(abSliderEl2){\n    abSliderEl2.addEventListener('input', function(){\n      var deg = parseFloat(this.value);\n      if(isNaN(deg)) deg = 0;\n      userAxisDeg = deg;\n      \/\/ Manual slider clears the AB-coords anchor \u2014 the user is now\n      \/\/ dialling a bare angle, not a coords-based line.\n      userAxisAnchor = null;\n      \/\/ Slider input always means custom mode \u2014 switch into it if not\n      \/\/ already there, so the picker UI reflects what the user just did.\n      if(headingStrategy !== 'custom'){\n        headingStrategy = 'custom';\n        updateHeadingPickerUI();\n      }\n      if(abAutoBtnEl) abAutoBtnEl.classList.remove('is-auto');\n      scheduleRecomputeFast();\n    });\n  }\n  if(abAutoBtnEl){\n    abAutoBtnEl.classList.add('is-auto');\n    abAutoBtnEl.addEventListener('click', function(){\n      \/\/ Reset \u2192 Longest Edge (the previous \"auto\" behaviour)\n      headingStrategy = 'longest';\n      userAxisDeg = null;\n      userAxisAnchor = null;  \/\/ Auto reset also drops the GPS anchor.\n      this.classList.add('is-auto');\n      updateHeadingPickerUI();\n      recompute();\n    });\n  }\n  \/\/ AB direction by GPS coordinates \u2014 useful when the operator has the\n  \/\/ exact A and B GPS waypoints from a prior season and wants to lock\n  \/\/ the simulator's axis to that recorded line. Computes the bearing\n  \/\/ (in canvas coords, accounting for y inversion) and applies it as\n  \/\/ the custom AB angle.\n  var abCoordsApplyEl = document.getElementById('gpl-ab-coords-apply');\n  var abCoordsHintEl = document.getElementById('gpl-ab-coords-hint');\n  function setAbCoordsHint(text, kind){\n    if(!abCoordsHintEl) return;\n    abCoordsHintEl.textContent = text || '';\n    abCoordsHintEl.classList.remove('is-error', 'is-success');\n    if(kind) abCoordsHintEl.classList.add('is-' + kind);\n  }\n  if(abCoordsApplyEl){\n    abCoordsApplyEl.addEventListener('click', function(){\n      var aLatEl = document.getElementById('gpl-ab-a-lat');\n      var aLngEl = document.getElementById('gpl-ab-a-lng');\n      var bLatEl = document.getElementById('gpl-ab-b-lat');\n      var bLngEl = document.getElementById('gpl-ab-b-lng');\n      var aLat = parseFloat((aLatEl || {}).value);\n      var aLng = parseFloat((aLngEl || {}).value);\n      var bLat = parseFloat((bLatEl || {}).value);\n      var bLng = parseFloat((bLngEl || {}).value);\n      function badLat(v){ return isNaN(v) ? true : (v < -90 ? true : v > 90); }\n      function badLng(v){ return isNaN(v) ? true : (v < -180 ? true : v > 180); }\n      if(badLat(aLat) ? true : badLng(aLng) ? true : badLat(bLat) ? true : badLng(bLng)){\n        setAbCoordsHint('Enter valid lat (\u221290 to 90) + lng (\u2212180 to 180) for both points.', 'error');\n        return;\n      }\n      \/\/ Same chord vector check both points use \u2014 reject zero-length\n      \/\/ (A and B at the same point can't define a direction).\n      var cosLatAvg = Math.cos((aLat + bLat) * 0.5 * Math.PI \/ 180);\n      var dx = (bLng - aLng) * 111320 * cosLatAvg;\n      var dy = -(bLat - aLat) * 111320;  \/\/ canvas y axis is inverted vs latitude\n      var dist = Math.sqrt(dx * dx + dy * dy);\n      if(dist < 1){\n        setAbCoordsHint('A and B are essentially the same point. Move them at least a few metres apart.', 'error');\n        return;\n      }\n      var deg = Math.atan2(dy, dx) * 180 \/ Math.PI;\n      while(deg < 0) deg += 180;\n      while(deg >= 180) deg -= 180;\n      userAxisDeg = deg;\n      headingStrategy = 'custom';\n      if(abSliderEl2) abSliderEl2.value = String(Math.round(deg));\n      var customRadio = document.querySelector('input[name=gpl-heading][value=custom]');\n      if(customRadio) customRadio.checked = true;\n      if(abAutoBtnEl) abAutoBtnEl.classList.remove('is-auto');\n      if(typeof updateHeadingPickerUI === 'function') updateHeadingPickerUI();\n      \/\/ Project A into canvas coords so the pass grid snaps THROUGH A.\n      \/\/ Only meaningful for fields with a geographic projection origin\n      \/\/ (imported boundaries \/ lines). For built-in sample fields the\n      \/\/ EXPORT_ORIGIN_LNG is null and the anchor is left unset \u2192 grid\n      \/\/ only borrows the bearing.\n      userAxisAnchor = null;\n      var hasOrigin = (typeof EXPORT_ORIGIN_LNG === 'number' ? typeof EXPORT_ORIGIN_LAT === 'number' : false);\n      if(hasOrigin){\n        var aMx = (aLng - EXPORT_ORIGIN_LNG) * 111320 * EXPORT_COSLAT;\n        var aMy = -(aLat - EXPORT_ORIGIN_LAT) * 111320;\n        \/\/ The same shift that applyCombinedGeoImport applied to bring\n        \/\/ the field to (380, 290). Stored in EXPORT_SHIFT_X \/ _Y.\n        var ax = aMx + (typeof EXPORT_SHIFT_X === 'number' ? EXPORT_SHIFT_X : 0);\n        var ay = aMy + (typeof EXPORT_SHIFT_Y === 'number' ? EXPORT_SHIFT_Y : 0);\n        userAxisAnchor = { x: ax, y: ay };\n      }\n      var snapMsg = hasOrigin ? ' \u00b7 grid snapped through A' : '';\n      setAbCoordsHint('AB direction set to ' + deg.toFixed(1) + '\u00b0 (chord ' + dist.toFixed(0) + ' m)' + snapMsg + '.', 'success');\n      recompute();\n    });\n  }\n  \/\/ \"Reset all blocks\" button \u2014 clears every per-block angle override\n  \/\/ and recomputes. Each block falls back to its algorithmic PCA axis.\n  var blocksResetAll = document.getElementById('gpl-blocks-reset');\n  if(blocksResetAll){\n    blocksResetAll.addEventListener('click', function(){\n      BLOCK_AXIS_OVERRIDES = {};\n      recompute();\n    });\n  }\n  \/\/ Sortable Compare All table. Click any header cell to sort rows by\n  \/\/ that column. Toggle ascending \/ descending on repeated clicks. The\n  \/\/ header row + the \"Your current lines\" \/ current-approach rows stay\n  \/\/ in their natural position; only the algorithm rows shuffle.\n  \/\/ Sortable Compare All table. Defaults to \"cost ascending\" (cheapest\n  \/\/ approach on top) per client feedback \u2014 the user lands on the table\n  \/\/ and immediately sees the best-value option without clicking. Any\n  \/\/ header click overrides; same column twice flips direction.\n  var __cmpSortKey = 'cost';\n  var __cmpSortDir = 1;\n  function applyCmpSort(){\n    var table = document.getElementById('gpl-cmp-table');\n    if(!table) return;\n    if(!__cmpSortKey) return;\n    var rows = Array.prototype.slice.call(table.querySelectorAll('.gpl-cmp-row'));\n    if(rows.length === 0) return;\n    var head = rows.shift();\n    if(!head) return;\n    var sortable = rows.filter(function(r){ return !r.hidden; });\n    sortable.sort(function(a, b){\n      if(__cmpSortKey === 'name'){\n        var an = (a.querySelector('.gpl-cmp-name') || {}).textContent || '';\n        var bn = (b.querySelector('.gpl-cmp-name') || {}).textContent || '';\n        return an.localeCompare(bn) * __cmpSortDir;\n      }\n      var av = parseFloat(a.dataset[__cmpSortKey] || 'NaN');\n      var bv = parseFloat(b.dataset[__cmpSortKey] || 'NaN');\n      if(isNaN(av)) av = __cmpSortDir > 0 ? Infinity : -Infinity;\n      if(isNaN(bv)) bv = __cmpSortDir > 0 ? Infinity : -Infinity;\n      return (av - bv) * __cmpSortDir;\n    });\n    for(var i=0; i<sortable.length; i++) table.appendChild(sortable[i]);\n    var sortEls = head.querySelectorAll('.gpl-cmp-sort');\n    for(var si=0; si<sortEls.length; si++){\n      sortEls[si].classList.remove('is-sort-asc', 'is-sort-desc');\n      if(sortEls[si].getAttribute('data-sort') === __cmpSortKey){\n        sortEls[si].classList.add(__cmpSortDir > 0 ? 'is-sort-asc' : 'is-sort-desc');\n      }\n    }\n  }\n  (function wireCmpSort(){\n    var table = document.getElementById('gpl-cmp-table');\n    if(!table) return;\n    var headers = table.querySelectorAll('.gpl-cmp-head .gpl-cmp-sort');\n    for(var hi=0; hi<headers.length; hi++){\n      headers[hi].addEventListener('click', function(ev){\n        \/\/ Click on the info icon inside the header \u2192 show tooltip\n        \/\/ only, don't trigger sort.\n        if(ev.target ? ev.target.classList.contains('gpl-info-ico') : false) return;\n        var k = this.getAttribute('data-sort');\n        if(!k) return;\n        if(__cmpSortKey === k) __cmpSortDir = -__cmpSortDir;\n        else { __cmpSortKey = k; __cmpSortDir = 1; }\n        applyCmpSort();\n      });\n    }\n    \/\/ Make every info icon inside the Compare All table tooltip-only:\n    \/\/ tabindex so :focus reveals the data-tip on touch, mousedown\n    \/\/ preventDefault blocks the label-style sort activation, click\n    \/\/ stopPropagation seals it.\n    var cmpIcons = table.querySelectorAll('.gpl-info-ico');\n    for(var ci=0; ci<cmpIcons.length; ci++){\n      cmpIcons[ci].setAttribute('tabindex', '0');\n      cmpIcons[ci].setAttribute('role', 'button');\n      cmpIcons[ci].setAttribute('aria-label', cmpIcons[ci].getAttribute('data-tip') || '');\n      cmpIcons[ci].addEventListener('mousedown', function(ev){ ev.preventDefault(); ev.stopPropagation(); });\n      cmpIcons[ci].addEventListener('click', function(ev){ ev.preventDefault(); ev.stopPropagation(); try { this.focus(); } catch(_){} });\n    }\n    \/\/ Make every NON-header row clickable: clicking a row selects that\n    \/\/ approach (syncs the left-panel radio + recomputes), so the user\n    \/\/ can switch approaches from the Compare All table without\n    \/\/ scrolling back to the Approach picker. Reported 2026-06-04.\n    var cmpRows = table.querySelectorAll('.gpl-cmp-row:not(.gpl-cmp-head)');\n    for(var rci=0; rci<cmpRows.length; rci++){\n      cmpRows[rci].style.cursor = 'pointer';\n      cmpRows[rci].setAttribute('role', 'button');\n      cmpRows[rci].setAttribute('tabindex', '0');\n      cmpRows[rci].addEventListener('click', function(ev){\n        \/\/ Ignore clicks on the info icon (tooltip-only)\n        if(ev.target ? ev.target.classList.contains('gpl-info-ico') : false) return;\n        var ap = this.getAttribute('data-cmp');\n        if(!ap) return;\n        \/\/ Skip uploaded if no upload available (the row is hidden anyway).\n        var apRadio = document.querySelector('#gpl-approach input[value=\"' + ap + '\"]');\n        if(!apRadio) return;\n        if(apRadio.disabled) return;\n        \/\/ Skip the adaptive PRO lock for imported fields \u2014 pick another row.\n        var lab = apRadio.parentNode;\n        if(lab ? lab.classList.contains('is-pro-locked') : false) return;\n        \/\/ Save scroll positions so neither the page nor the side panel\n        \/\/ jumps when we programmatically check the radio + recompute\n        \/\/ (radio is above the fold; programmatic check + change handler\n        \/\/ can pull the viewport to the top in some browsers \u2014 reported\n        \/\/ 2026-06-04 \"when i click on the table - don't jump page\").\n        var savedY = window.pageYOffset ? window.pageYOffset : (document.documentElement.scrollTop || document.body.scrollTop || 0);\n        var savedX = window.pageXOffset ? window.pageXOffset : (document.documentElement.scrollLeft || document.body.scrollLeft || 0);\n        var sideEl = document.querySelector('.gpl-side');\n        var sideSavedY = sideEl ? sideEl.scrollTop : 0;\n        apRadio.checked = true;\n        \/\/ Sync the .is-on highlight + fire the change handler so the\n        \/\/ app's existing approach-select logic runs.\n        var ev2 = document.createEvent ? document.createEvent('HTMLEvents') : null;\n        if(ev2){ ev2.initEvent('change', true, true); apRadio.dispatchEvent(ev2); }\n        else { try { apRadio.dispatchEvent(new Event('change', { bubbles: true })); } catch(_){} }\n        \/\/ Restore page + side-panel scroll on the next frame (after the\n        \/\/ browser's implicit \"scroll focused element into view\" runs).\n        var restoreScroll = function(){\n          try { window.scrollTo(savedX, savedY); } catch(_){}\n          if(sideEl) sideEl.scrollTop = sideSavedY;\n        };\n        if(typeof requestAnimationFrame === 'function') requestAnimationFrame(restoreScroll);\n        else setTimeout(restoreScroll, 0);\n      });\n      cmpRows[rci].addEventListener('keydown', function(ev){\n        \/\/ Keyboard accessibility: Enter \/ Space activate the row.\n        if(ev.key === 'Enter' ? true : ev.key === ' '){\n          ev.preventDefault();\n          try { this.click(); } catch(_){}\n        }\n      });\n    }\n    \/\/ Apply default sort on page load (cost ascending).\n    applyCmpSort();\n  })();\n  \/\/ PRO badge on Topography follow opens the GeoPard signup CTA. The\n  \/\/ badge only shows for uploaded fields (where we don't have real\n  \/\/ elevation data); on the built-in sample fields the badge is\n  \/\/ hidden and adaptive works against the synthetic terrain.\n  var proLockEl = document.getElementById('gpl-ap-adaptive-lock');\n  if(proLockEl){\n    proLockEl.addEventListener('click', function(ev){\n      ev.stopPropagation();\n      ev.preventDefault();\n      window.open('https:\/\/app.geopard.tech\/signup?utm_source=guidance-lines\\x26utm_medium=wp-embed\\x26utm_campaign=topography-pro', '_blank', 'noopener');\n    });\n  }\n  \/\/ Plan-view toggle (Proposed vs Your current). Shown only when the\n  \/\/ user has uploaded their existing guidance lines. Switching to\n  \/\/ \"Your current\" plays back + analyses the upload in the same UI as\n  \/\/ the proposed plan, so the user gets a side-by-side comparison\n  \/\/ without manually reading the Compare All table.\n  \/\/ Show \/ hide the \"Your current lines\" radio in the Approach picker\n  \/\/ based on whether the user has uploaded existing guidance lines.\n  \/\/ When the upload is cleared (back to a sample field), auto-bounce\n  \/\/ off 'uploaded' if it was selected so the picker doesn't sit on a\n  \/\/ hidden option.\n  function updatePlanViewToggle(){\n    var lab = document.getElementById('gpl-ap-uploaded-label');\n    var has = UPLOADED_LINES ? UPLOADED_LINES.length > 0 : false;\n    if(lab) lab.hidden = !has;\n    if(!has ? current === 'uploaded' : false){\n      current = 'ab-straight';\n      var ab = document.querySelector('#gpl-approach input[value=\"ab-straight\"]');\n      if(ab){\n        ab.checked = true;\n        var ls = document.querySelectorAll('#gpl-approach label');\n        for(var lsi=0; lsi<ls.length; lsi++) ls[lsi].classList.remove('is-on');\n        ab.parentNode.classList.add('is-on');\n      }\n    }\n  }\n  \/\/ Heading-picker strategy radios. Listen on the radio's change event so\n  \/\/ both keyboard nav (tab + arrows) and click work consistently.\n  var headingRadios = document.querySelectorAll('.gpl-heading-opt input[type=\"radio\"]');\n  for(var ho=0; ho<headingRadios.length; ho++){\n    headingRadios[ho].addEventListener('change', function(){\n      if(this.checked) applyHeadingStrategy(this.value);\n    });\n  }\n  \/\/ Playback controls\n  var lastLayout = null;\n  var LAST_LAYOUT = null;  \/\/ shared with the export functions\n  var pbBtnEl = document.getElementById('gpl-pb-play');\n  var pbTrackEl = document.getElementById('gpl-pb-track');\n  var pbSpdEls = document.querySelectorAll('.gpl-pb-spd');\n  \/\/ Cache the current layout so the RAF loop can redraw without recomputing\n  \/\/ the whole geometry pipeline on every frame.\n  var origDraw = draw;\n  draw = function(layout){ lastLayout = layout; LAST_LAYOUT = layout; origDraw(layout); };\n  function tick(now){\n    if(!playback.isPlaying){ playback.lastTick = now; return; }\n    var dt = playback.lastTick > 0 ? (now - playback.lastTick) \/ 1000 : 0;\n    playback.lastTick = now;\n    \/\/ Path-relative playback speed. Two regimes:\n    \/\/\n    \/\/   1. Small\/medium fields (\u2264 ~24 km drive): target 12 s at the\n    \/\/      floor and ~1500 m\/s baseline, so the 5-ha sample fields\n    \/\/      don't drag and a typical 10-ha plot finishes in ~15 s.\n    \/\/\n    \/\/   2. Large fields (200 km+ drive): the old 25 s upper cap blew\n    \/\/      through them at 8000 m\/s \u2014 each pass took < 0.1 s and the\n    \/\/      operator couldn't see what was happening (reported on\n    \/\/      460 ha real client field). Now: m\/s drops with sqrt(field\n    \/\/      size) so big fields slow down to ~700 m\/s, and the upper\n    \/\/      duration cap is 90 s so a 200 km plan plays in ~90 s\n    \/\/      instead of 25 s.\n    var bigness = playback.totalLen \/ 50000;  \/\/ 1.0 at ~50 km, >1 huge\n    var mpsTarget = bigness > 1\n      ? 1500 \/ Math.sqrt(bigness)  \/\/ 1500 \u2192 ~500 m\/s as field grows\n      : 1500;\n    if(mpsTarget < 500) mpsTarget = 500;\n    var targetSec = playback.totalLen \/ mpsTarget;\n    if(targetSec < 12) targetSec = 12;\n    if(targetSec > 90) targetSec = 90;\n    var metresPerSec = playback.totalLen > 0 ? (playback.totalLen \/ targetSec) * playback.speed : 0;\n    var dT = playback.totalLen > 0 ? (dt * metresPerSec \/ playback.totalLen) : 0;\n    playback.t += dT;\n    if(playback.t >= 1){\n      playback.t = 1;\n      playback.isPlaying = false;\n      if(pbBtnEl){\n        pbBtnEl.classList.remove('is-playing');\n        pbBtnEl.textContent = '\u25b6';\n      }\n    }\n    updatePlaybackUI();\n    if(lastLayout) origDraw(lastLayout);\n    if(playback.isPlaying) requestAnimationFrame(tick);\n  }\n  if(pbBtnEl){\n    pbBtnEl.addEventListener('click', function(){\n      if(playback.totalLen < 1) return;\n      if(playback.t >= 1) playback.t = 0;\n      playback.isPlaying = !playback.isPlaying;\n      pbBtnEl.classList.toggle('is-playing', playback.isPlaying);\n      pbBtnEl.textContent = playback.isPlaying ? '' : '\u25b6';\n      if(playback.isPlaying){\n        playback.lastTick = 0;\n        requestAnimationFrame(tick);\n      }\n    });\n  }\n  if(pbTrackEl){\n    pbTrackEl.addEventListener('click', function(ev){\n      var rect = pbTrackEl.getBoundingClientRect();\n      var f = (ev.clientX - rect.left) \/ rect.width;\n      if(f < 0) f = 0; if(f > 1) f = 1;\n      playback.t = f;\n      updatePlaybackUI();\n      if(lastLayout) origDraw(lastLayout);\n    });\n  }\n  for(var sb=0; sb<pbSpdEls.length; sb++){\n    pbSpdEls[sb].addEventListener('click', function(){\n      var s = parseFloat(this.getAttribute('data-spd')) || 1;\n      playback.speed = s;\n      for(var sb2=0; sb2<pbSpdEls.length; sb2++) pbSpdEls[sb2].classList.remove('on');\n      this.classList.add('on');\n    });\n  }\n  \/\/ Unit-system toggle (Metric \/ US). Switches:\n  \/\/   \u2022 All display formatters (fmtArea \/ fmtVolume \/ fmtWidth \/ fmtDist)\n  \/\/   \u2022 Input field VALUES \u2014 multiplied\/divided by the conversion factor so\n  \/\/     the user sees the same physical quantity expressed in the new units.\n  \/\/     Example: 18 m implement width becomes 59 ft on switch to US.\n  \/\/   \u2022 Input field unit LABELS (the small \"m\" \/ \"ft\", \"L\/km\" \/ \"gal\/mi\",\n  \/\/     \"$\/L\" \/ \"$\/gal\" badges next to each field).\n  \/\/ After conversion, recompute() reads the converted values via getInputs(),\n  \/\/ which reverses the conversion so the geometry pipeline stays metric.\n  function setUnitSystem(sys){\n    if(sys !== 'metric' ? sys !== 'us' : false) sys = 'metric';\n    if(sys === unitSystem) return;\n    unitSystem = sys;\n    \/\/ Convert + display, preserving the canonical METRIC value across\n    \/\/ toggles so a round-trip never drifts. Without this, wM 15 m \u2192 49 ft\n    \/\/ \u2192 14.93 m (display rounding), and the slightly different wM tipped\n    \/\/ the metric-based BEST recommender on borderline fields (reported\n    \/\/ 2026-06-04 \u2014 \"switching US \u2194 metric changes the best approach\").\n    \/\/ factorMetricToUS is the metric\u2192US scale; pass the SAME value for\n    \/\/ both directions, the helper reads it directionally.\n    function convertInput(id, factorMetricToUS, decimals){\n      var el = document.getElementById(id);\n      if(!el) return;\n      if(sys === 'us'){\n        \/\/ metric \u2192 US: snapshot the metric value, display rounded US.\n        var v = parseFloat(el.value);\n        if(isNaN(v)) return;\n        el.setAttribute('data-metric', String(v));\n        var usVal = v * factorMetricToUS;\n        el.value = decimals > 0 ? usVal.toFixed(decimals) : Math.round(usVal).toString();\n      } else {\n        \/\/ US \u2192 metric: restore the snapshotted metric value if present\n        \/\/ (lossless round-trip); else fall back to parsing the displayed\n        \/\/ US and converting (first-run case, no snapshot yet).\n        var saved = parseFloat(el.getAttribute('data-metric'));\n        var mVal;\n        if(isNaN(saved)){\n          var v2 = parseFloat(el.value);\n          if(isNaN(v2)) return;\n          mVal = v2 \/ factorMetricToUS;\n        } else {\n          mVal = saved;\n        }\n        el.value = decimals > 0 ? mVal.toFixed(decimals) : Math.round(mVal).toString();\n      }\n    }\n    convertInput('gpl-wm',       M_TO_FT,      0);\n    convertInput('gpl-turn-r',   M_TO_FT,      1);\n    convertInput('gpl-cons',     LPKM_TO_GPMI, 2);\n    convertInput('gpl-fuel',     LPL_TO_DPGAL, 2);\n    convertInput('gpl-roi-farm', HA_TO_AC,     0);\n    convertInput('gpl-speed',    KM_TO_MI,     1);\n    \/\/ Swap unit labels.\n    var us = sys === 'us';\n    var setLabel = function(sel, txt){\n      var el = document.querySelector(sel);\n      if(el) el.textContent = txt;\n    };\n    setLabel('[data-u=\"width\"]',            us ? 'ft' : 'm');\n    setLabel('[data-u=\"turn-r\"]',           us ? 'ft' : 'm');\n    setLabel('[data-u=\"farm\"]',             us ? 'ac' : 'ha');\n    setLabel('[data-u=\"consumption-label\"]', us ? 'gal\/mi' : 'L\/km');\n    setLabel('[data-u=\"speed\"]',            us ? 'mph' : 'km\/h');\n    \/\/ Currency dropdown options carry \/L or \/gal \u2014 adjust both options.\n    var curSel = document.getElementById('gpl-currency');\n    if(curSel){\n      var opts = curSel.querySelectorAll('option');\n      for(var oi=0; oi<opts.length; oi++){\n        var v = opts[oi].value;\n        opts[oi].textContent = (v === 'usd' ? '$' : '\u20ac') + (us ? '\/gal' : '\/L');\n      }\n    }\n    \/\/ Step size on the diesel input \u2014 finer in $\/L (0.05), coarser in\n    \/\/ $\/gal (since 0.05 $\/L \u2248 0.19 $\/gal). Keep precision symmetric.\n    var fuelEl = document.getElementById('gpl-fuel');\n    if(fuelEl) fuelEl.step = us ? '0.05' : '0.05';\n    \/\/ Headland slider label (currently \"X m \u00b7 Nx pass\") needs a refresh.\n    updateHeadlandLabel();\n    \/\/ Re-trigger any text that depends on unit (e.g. Turn-r hint).\n    var trHintEl = document.getElementById('gpl-turn-r-hint');\n    if(trHintEl ? trHintEl.textContent.indexOf('auto') === 0 : false){\n      \/\/ leave the \"auto \u00b7 std tractor\" hint alone; numeric auto hints get\n      \/\/ refreshed naturally by the wM-input handler on next change\n    }\n    recompute();\n  }\n  var unitTabs = document.querySelectorAll('.gpl-unit-tab');\n  for(var ut=0; ut<unitTabs.length; ut++){\n    unitTabs[ut].addEventListener('click', function(){\n      var sys = this.getAttribute('data-unit-system') || 'metric';\n      for(var ut2=0; ut2<unitTabs.length; ut2++) unitTabs[ut2].classList.remove('is-on');\n      this.classList.add('is-on');\n      setUnitSystem(sys);\n    });\n  }\n  \/\/ Invalidate the metric snapshot whenever the user manually edits an\n  \/\/ input \u2014 otherwise the saved metric value would override their new\n  \/\/ entry on the next unit toggle. Pairs with setUnitSystem's\n  \/\/ data-metric round-trip preservation logic.\n  var unitInvIds = ['gpl-wm', 'gpl-turn-r', 'gpl-cons', 'gpl-fuel', 'gpl-roi-farm', 'gpl-speed'];\n  for(var uii=0; uii<unitInvIds.length; uii++){\n    var elInv = document.getElementById(unitInvIds[uii]);\n    if(elInv) elInv.addEventListener('input', function(){ this.removeAttribute('data-metric'); });\n  }\n  \/\/ Zoom + fit buttons\n  \/\/ Zoom-around-cursor helper. Adjusts pan so the world point under\n  \/\/ (mx, my) stays at the same screen position after the zoom change.\n  function zoomAroundCursor(mx, my, factor){\n    var projBefore = getScale();\n    var wx = worldX(projBefore, mx);\n    var wy = worldY(projBefore, my);\n    var newZoom = view.zoom * factor;\n    if(newZoom < 0.2) newZoom = 0.2;\n    if(newZoom > 50) newZoom = 50;\n    view.zoom = newZoom;\n    var projAfter = getScale();\n    view.panX += mx - px(projAfter, wx);\n    view.panY += my - py(projAfter, wy);\n    if(lastLayout) origDraw(lastLayout);\n  }\n  function zoomCenter(factor){\n    var rect = canvas.getBoundingClientRect();\n    zoomAroundCursor(rect.width * 0.5, rect.height * 0.5, factor);\n  }\n  var zInEl = document.getElementById('gpl-tool-zoom-in');\n  var zOutEl = document.getElementById('gpl-tool-zoom-out');\n  var zFitEl = document.getElementById('gpl-tool-fit');\n  if(zInEl) zInEl.addEventListener('click', function(){ zoomCenter(1.3); });\n  if(zOutEl) zOutEl.addEventListener('click', function(){ zoomCenter(1\/1.3); });\n  if(zFitEl) zFitEl.addEventListener('click', function(){\n    view.zoom = 1; view.panX = 0; view.panY = 0;\n    if(lastLayout) origDraw(lastLayout);\n  });\n  \/\/ Export button \u2014 toggles a small menu with format options. Each\n  \/\/ option calls the matching exporter, which produces a download via\n  \/\/ an anchor + Blob URL.\n  var exportBtnEl = document.getElementById('gpl-tool-export');\n  var exportMenuEl = document.getElementById('gpl-export-menu');\n  if(exportBtnEl ? exportMenuEl : false){\n    exportBtnEl.addEventListener('click', function(ev){\n      ev.stopPropagation();\n      exportMenuEl.hidden = !exportMenuEl.hidden;\n    });\n    var fmtBtns = exportMenuEl.querySelectorAll('button[data-fmt]');\n    for(var fb=0; fb<fmtBtns.length; fb++){\n      fmtBtns[fb].addEventListener('click', function(){\n        var fmt = this.getAttribute('data-fmt');\n        exportMenuEl.hidden = true;\n        if(fmt === 'geojson') exportGeoJSON();\n        else if(fmt === 'kml') exportKML();\n        else if(fmt === 'shp') exportShapefile();\n      });\n    }\n    \/\/ Dismiss the menu on click-outside.\n    document.addEventListener('click', function(ev){\n      if(exportMenuEl.hidden) return;\n      if(exportBtnEl.contains(ev.target) ? true : exportMenuEl.contains(ev.target)) return;\n      exportMenuEl.hidden = true;\n    });\n  }\n  \/\/ Floating CTA: always shown on page load (no dismiss, no persistence).\n  \/\/ Previously a \u00d7 button persisted \"hidden\" in sessionStorage; per\n  \/\/ product direction the CTA stays visible the whole session.\n  \/\/ Mouse wheel \u2014 smooth exponential zoom (matches field-data-explorer feel)\n  canvas.addEventListener('wheel', function(ev){\n    ev.preventDefault();\n    var rect = canvas.getBoundingClientRect();\n    var mx = ev.clientX - rect.left;\n    var my = ev.clientY - rect.top;\n    var factor = Math.exp(-ev.deltaY * 0.0015);\n    zoomAroundCursor(mx, my, factor);\n  }, { passive: false });\n  \/\/ Drag-to-pan (when ruler is not active)\n  var dragState = null;\n  canvas.addEventListener('mousedown', function(ev){\n    var rect = canvas.getBoundingClientRect();\n    var mx = ev.clientX - rect.left;\n    var my = ev.clientY - rect.top;\n    if(ruler.active){\n      var proj = getScale();\n      var wx = worldX(proj, mx);\n      var wy = worldY(proj, my);\n      if(!ruler.p1){\n        ruler.p1 = { x: wx, y: wy }; ruler.p2 = null;\n      } else if(!ruler.p2){\n        ruler.p2 = { x: wx, y: wy };\n      } else {\n        ruler.p1 = { x: wx, y: wy }; ruler.p2 = null;\n      }\n      if(lastLayout) origDraw(lastLayout);\n      return;\n    }\n    if(abTool.active){\n      var projA = getScale();\n      var wxA = worldX(projA, mx);\n      var wyA = worldY(projA, my);\n      if(!abTool.p1){\n        abTool.p1 = { x: wxA, y: wyA };\n        abTool.p2 = null;\n        var hintAB1 = document.getElementById('gpl-ab-hint');\n        if(hintAB1) hintAB1.textContent = 'Click the second point to lock the AB line \u00b7 ESC to cancel';\n        if(lastLayout) origDraw(lastLayout);\n      } else {\n        abTool.p2 = { x: wxA, y: wyA };\n        \/\/ Compute the angle from the two points and push it into userAxisDeg\n        \/\/ so generateLinesAll uses this axis instead of the PCA default.\n        var abdx = abTool.p2.x - abTool.p1.x;\n        var abdy = abTool.p2.y - abTool.p1.y;\n        if(abdx * abdx + abdy * abdy < 1){\n          \/\/ Same click twice \u2014 ignore (need a real vector)\n          abTool.p2 = null;\n          return;\n        }\n        var deg = Math.atan2(abdy, abdx) * 180 \/ Math.PI;\n        while(deg < 0) deg += 180;\n        while(deg >= 180) deg -= 180;\n        userAxisDeg = deg;\n        var abAutoBtn2 = document.getElementById('gpl-ab-auto');\n        if(abAutoBtn2) abAutoBtn2.classList.remove('is-auto');\n        var abSliderEl3 = document.getElementById('gpl-ab-deg');\n        if(abSliderEl3) abSliderEl3.value = String(Math.round(deg));\n        \/\/ Exit AB-tool mode but keep the vector visible so the user sees what they set.\n        abTool.active = false;\n        var btnAB = document.getElementById('gpl-tool-ab');\n        if(btnAB) btnAB.classList.remove('is-on');\n        var hintAB2 = document.getElementById('gpl-ab-hint');\n        if(hintAB2) hintAB2.classList.remove('is-on');\n        canvas.style.cursor = 'default';\n        recompute();\n      }\n      return;\n    }\n    dragState = { startX: mx, startY: my, startPanX: view.panX, startPanY: view.panY };\n    canvas.style.cursor = 'grabbing';\n  });\n  canvas.addEventListener('mousemove', function(ev){\n    if(!dragState) return;\n    var rect = canvas.getBoundingClientRect();\n    var mx = ev.clientX - rect.left;\n    var my = ev.clientY - rect.top;\n    view.panX = dragState.startPanX + (mx - dragState.startX);\n    view.panY = dragState.startPanY + (my - dragState.startY);\n    if(lastLayout) origDraw(lastLayout);\n  });\n  function endDrag(){\n    dragState = null;\n    canvas.style.cursor = ruler.active ? 'crosshair' : 'default';\n  }\n  canvas.addEventListener('mouseup', endDrag);\n  canvas.addEventListener('mouseleave', endDrag);\n  \/\/ Touch \u2014 single-finger pan, two-finger pinch-zoom (mirrors field-explorer)\n  var touchState = null;\n  function _tDist(a, b){ var dx = a.clientX - b.clientX, dy = a.clientY - b.clientY; return Math.sqrt(dx*dx + dy*dy); }\n  function _tCenter(a, b){ return [(a.clientX + b.clientX) * 0.5, (a.clientY + b.clientY) * 0.5]; }\n  canvas.addEventListener('touchstart', function(ev){\n    if(ev.touches.length === 1){\n      ev.preventDefault();\n      var rect = canvas.getBoundingClientRect();\n      touchState = { mode: 'pan', x: ev.touches[0].clientX, y: ev.touches[0].clientY, sx: ev.touches[0].clientX - rect.left, sy: ev.touches[0].clientY - rect.top, panX: view.panX, panY: view.panY, moved: false };\n    } else if(ev.touches.length >= 2){\n      ev.preventDefault();\n      var c = _tCenter(ev.touches[0], ev.touches[1]);\n      touchState = { mode: 'pinch', dist: _tDist(ev.touches[0], ev.touches[1]), cx: c[0], cy: c[1] };\n    }\n  }, { passive: false });\n  canvas.addEventListener('touchmove', function(ev){\n    if(!touchState) return;\n    ev.preventDefault();\n    if(touchState.mode === 'pan' ? ev.touches.length === 1 : false){\n      var t = ev.touches[0];\n      var dx = t.clientX - touchState.x;\n      var dy = t.clientY - touchState.y;\n      view.panX = touchState.panX + dx;\n      view.panY = touchState.panY + dy;\n      if(Math.abs(dx) > 4 ? true : Math.abs(dy) > 4) touchState.moved = true;\n      if(lastLayout) origDraw(lastLayout);\n    } else if(touchState.mode === 'pinch' ? ev.touches.length >= 2 : false){\n      var newDist = _tDist(ev.touches[0], ev.touches[1]);\n      var newC = _tCenter(ev.touches[0], ev.touches[1]);\n      var rectT = canvas.getBoundingClientRect();\n      var ncx = newC[0] - rectT.left, ncy = newC[1] - rectT.top;\n      zoomAroundCursor(ncx, ncy, newDist \/ touchState.dist);\n      touchState.dist = newDist; touchState.cx = newC[0]; touchState.cy = newC[1];\n    }\n  }, { passive: false });\n  canvas.addEventListener('touchend', function(ev){\n    \/\/ Ruler tap support: if ruler mode and no drag occurred, drop a point at the touch position\n    if(touchState ? (touchState.mode === 'pan' ? (!touchState.moved ? ruler.active : false) : false) : false){\n      var proj = getScale();\n      var wx = worldX(proj, touchState.sx);\n      var wy = worldY(proj, touchState.sy);\n      if(!ruler.p1){ ruler.p1 = { x: wx, y: wy }; ruler.p2 = null; }\n      else if(!ruler.p2){ ruler.p2 = { x: wx, y: wy }; }\n      else { ruler.p1 = { x: wx, y: wy }; ruler.p2 = null; }\n      if(lastLayout) origDraw(lastLayout);\n    }\n    if(ev.touches.length === 0) touchState = null;\n  });\n  \/\/ Ruler toggle \u2014 mutually exclusive with the AB-line tool.\n  var rulerBtnEl = document.getElementById('gpl-tool-ruler');\n  var rulerHintEl = document.getElementById('gpl-ruler-hint');\n  var abBtnEl = document.getElementById('gpl-tool-ab');\n  var abHintEl = document.getElementById('gpl-ab-hint');\n  if(rulerBtnEl){\n    rulerBtnEl.addEventListener('click', function(){\n      ruler.active = !ruler.active;\n      this.classList.toggle('is-on', ruler.active);\n      if(rulerHintEl) rulerHintEl.classList.toggle('is-on', ruler.active);\n      \/\/ Cancel AB tool when activating ruler\n      if(ruler.active){\n        abTool.active = false;\n        if(abBtnEl) abBtnEl.classList.remove('is-on');\n        if(abHintEl) abHintEl.classList.remove('is-on');\n      }\n      canvas.style.cursor = ruler.active ? 'crosshair' : 'default';\n      if(!ruler.active){\n        ruler.p1 = null; ruler.p2 = null;\n        if(lastLayout) origDraw(lastLayout);\n      }\n    });\n  }\n  \/\/ Set AB line \u2014 click two points; angle replaces userAxisDeg.\n  if(abBtnEl){\n    abBtnEl.addEventListener('click', function(){\n      abTool.active = !abTool.active;\n      this.classList.toggle('is-on', abTool.active);\n      if(abHintEl){\n        abHintEl.classList.toggle('is-on', abTool.active);\n        if(abTool.active) abHintEl.textContent = 'Click the first point of your AB line \u00b7 ESC to cancel';\n      }\n      if(abTool.active){\n        ruler.active = false;\n        if(rulerBtnEl) rulerBtnEl.classList.remove('is-on');\n        if(rulerHintEl) rulerHintEl.classList.remove('is-on');\n        abTool.p1 = null; abTool.p2 = null;\n      }\n      canvas.style.cursor = abTool.active ? 'crosshair' : 'default';\n      if(lastLayout) origDraw(lastLayout);\n    });\n  }\n  \/\/ ESC key cancels either active tool.\n  window.addEventListener('keydown', function(ev){\n    if(ev.key !== 'Escape') return;\n    var dirty = false;\n    if(ruler.active){\n      ruler.active = false;\n      ruler.p1 = null; ruler.p2 = null;\n      if(rulerBtnEl) rulerBtnEl.classList.remove('is-on');\n      if(rulerHintEl) rulerHintEl.classList.remove('is-on');\n      dirty = true;\n    }\n    if(abTool.active){\n      abTool.active = false;\n      \/\/ Keep the previously-committed AB vector visible \u2014 only cancel the in-progress click capture.\n      if(abBtnEl) abBtnEl.classList.remove('is-on');\n      if(abHintEl) abHintEl.classList.remove('is-on');\n      dirty = true;\n    }\n    if(dirty){\n      canvas.style.cursor = 'default';\n      if(lastLayout) origDraw(lastLayout);\n    }\n  });\n  \/\/ Clear toolbar button \u2014 wipes ruler + custom AB line, resets the view to fit.\n  var clearBtnEl = document.getElementById('gpl-tool-clear');\n  if(clearBtnEl){\n    clearBtnEl.addEventListener('click', function(){\n      ruler.active = false; ruler.p1 = null; ruler.p2 = null;\n      abTool.active = false; abTool.p1 = null; abTool.p2 = null;\n      userAxisDeg = null;  \/\/ back to auto-PCA\n      headingStrategy = \"longest\";\n      var abAutoBtn3 = document.getElementById('gpl-ab-auto');\n      if(abAutoBtn3) abAutoBtn3.classList.add('is-auto');\n      if(rulerBtnEl) rulerBtnEl.classList.remove('is-on');\n      if(abBtnEl) abBtnEl.classList.remove('is-on');\n      if(rulerHintEl) rulerHintEl.classList.remove('is-on');\n      if(abHintEl) abHintEl.classList.remove('is-on');\n      view.zoom = 1; view.panX = 0; view.panY = 0;\n      canvas.style.cursor = 'default';\n      recompute();\n    });\n  }\n  \/\/ Resizable left panel \u2014 drag splitter to set --gpl-left-w on the wrap.\n  \/\/ Clamped to [200, 420] px so the panel never collapses or eats the canvas.\n  var splitterEl = document.getElementById('gpl-splitter');\n  if(splitterEl){\n    var spState = null;\n    splitterEl.addEventListener('mousedown', function(ev){\n      var current = parseFloat(getComputedStyle(root).getPropertyValue('--gpl-left-w')) || 320;\n      spState = { startX: ev.clientX, startW: current };\n      splitterEl.classList.add('is-drag');\n      ev.preventDefault();\n    });\n    window.addEventListener('mousemove', function(ev){\n      if(!spState) return;\n      var w = spState.startW + (ev.clientX - spState.startX);\n      if(w < 200) w = 200;\n      if(w > 420) w = 420;\n      root.style.setProperty('--gpl-left-w', w + 'px');\n      \/\/ Canvas size changed \u2192 refit and redraw\n      resize();\n      if(lastLayout) origDraw(lastLayout);\n    });\n    window.addEventListener('mouseup', function(){\n      if(!spState) return;\n      spState = null;\n      splitterEl.classList.remove('is-drag');\n    });\n  }\n  \/\/ Resizable right (analytics) panel \u2014 drag splitter to set --gpl-right-w.\n  \/\/ Same UX as the left splitter; dragging LEFT widens the panel (so the\n  \/\/ Compare All table + ROI numbers get more room when needed).\n  var splitterRightEl = document.getElementById('gpl-splitter-right');\n  if(splitterRightEl){\n    var srState = null;\n    splitterRightEl.addEventListener('mousedown', function(ev){\n      var current = parseFloat(getComputedStyle(root).getPropertyValue('--gpl-right-w')) || 320;\n      srState = { startX: ev.clientX, startW: current };\n      splitterRightEl.classList.add('is-drag');\n      ev.preventDefault();\n    });\n    window.addEventListener('mousemove', function(ev){\n      if(!srState) return;\n      \/\/ Drag LEFT widens (analytics panel expands toward the canvas), drag\n      \/\/ RIGHT narrows \u2014 invert the delta vs. the left splitter so the\n      \/\/ expand-toward-content gesture feels natural on both sides.\n      var w = srState.startW - (ev.clientX - srState.startX);\n      if(w < 200) w = 200;\n      if(w > 420) w = 420;\n      root.style.setProperty('--gpl-right-w', w + 'px');\n      resize();\n      if(lastLayout) origDraw(lastLayout);\n    });\n    window.addEventListener('mouseup', function(){\n      if(!srState) return;\n      srState = null;\n      splitterRightEl.classList.remove('is-drag');\n    });\n  }\n  \/\/ Initial recommendation \u2014 pre-select the shape-optimal approach so the\n  \/\/ page lands on the agronomically sensible default, not arbitrary AB Straight.\n  (function(){\n    var initialPick = recommendApproach(BOUNDARY).pick;\n    current = initialPick;\n    var apRadios0 = document.querySelectorAll('#gpl-approach input[type=radio]');\n    var apLabels0 = document.querySelectorAll('#gpl-approach label');\n    for(var ar0=0; ar0<apRadios0.length; ar0++){\n      var match0 = apRadios0[ar0].value === initialPick;\n      apRadios0[ar0].checked = match0;\n      if(apLabels0[ar0]) apLabels0[ar0].classList.toggle('is-on', match0);\n    }\n  })();\n  try {\n    resize();\n    updateHeadlandLabel();\n    applyMachineRadius();\n    \/\/ Initial page load is an auto-pick context \u2014 the deferred Compare All\n    \/\/ fill will then swap the radio to the metric-recommended best.\n    pendingAutoPick = true;\n    recompute();\n    autoPickBestApproach();\n    window.addEventListener('resize', function(){\n      try { resize(); recompute(); } catch(e2){ try { console.error('[GPL-RESIZE]', e2.message); } catch(_){} }\n    });\n  } catch(e1){\n    try { console.error('[GPL-INIT]', e1.message, e1.stack); } catch(_){}\n  }\n})();\n<\/script>\n<\/div>\n\n\n\n<!--\n  Guidance-lines-specific bottom (CTA + FAQ). Self-contained CSS so it\n  renders correctly WITHOUT depending on block 13098's .gpx-* styles\n  (which only ship with the field-explorer hero, not ours).\n\n  Replaces the shared Reusable Block 13099 reference on page 13171.\n  Pasted INLINE on the page as the last Custom HTML block.\n-->\n<div class=\"gpl-bottom\">\n<style>\n.gpl-bottom{font-family:var(--wp--preset--font-family--nunito,\"Nunito\",system-ui,-apple-system,Segoe UI,sans-serif);color:#212121;max-width:1700px;margin:0 auto;padding:0 8px}\n.gpl-bottom h1,.gpl-bottom h2,.gpl-bottom h3{font-family:var(--wp--preset--font-family--poppins,\"Poppins\",system-ui,-apple-system,sans-serif);font-weight:700;color:#145328;line-height:1.2;letter-spacing:-0.01em}\n.gpl-bottom h2{font-size:1.55rem;margin:28px 0 14px}\n.gpl-bottom h3{font-size:1.1rem;margin:0 0 10px;color:#145328}\n.gpl-bottom p{line-height:1.65;color:#2c3e2c;margin:0 0 14px}\n.gpl-bottom a{color:#15701e;text-decoration:underline;text-decoration-thickness:1px;text-underline-offset:2px}\n.gpl-bottom a:hover{color:#145328}\n.gpl-bottom strong{color:#145328}\n.gpl-bottom .gpb-cta{background:linear-gradient(135deg,#145328 0%,#1b7a2a 65%,#1a7951 100%);border-radius:18px;padding:38px 40px;margin:36px 0 24px;color:#fafbf4;text-align:center;position:relative;overflow:hidden}\n.gpl-bottom .gpb-cta::before{content:\"\";position:absolute;inset:auto -40px -60px auto;width:200px;height:200px;background:radial-gradient(circle,rgba(247,106,12,0.28),transparent 70%);pointer-events:none}\n.gpl-bottom .gpb-cta h2{color:#fff;margin:0 0 12px;font-size:1.6rem}\n.gpl-bottom .gpb-cta p{color:rgba(255,255,255,0.95);font-size:1.05rem;max-width:740px;margin:0 auto 22px}\n.gpl-bottom .gpb-btn{display:inline-block;background:#f76a0c;color:#fff;font-family:var(--wp--preset--font-family--poppins,\"Poppins\",sans-serif);font-weight:600;font-size:1rem;padding:14px 30px;border-radius:999px;text-decoration:none;transition:transform .15s,box-shadow .15s,background .15s;box-shadow:0 6px 18px rgba(247,106,12,0.4)}\n.gpl-bottom .gpb-btn:hover{transform:translateY(-1px);background:#ff7d24;color:#fff;box-shadow:0 8px 24px rgba(247,106,12,0.5)}\n.gpl-bottom .gpb-integ{display:flex;flex-wrap:wrap;gap:8px;justify-content:center;margin:10px 0 20px}\n.gpl-bottom .gpb-integ-label{font-size:.86rem;color:rgba(255,255,255,0.86);margin:0 0 8px;font-weight:500;letter-spacing:.01em}\n.gpl-bottom .gpb-integ-chip{background:rgba(255,255,255,0.16);border:1px solid rgba(255,255,255,0.32);color:#fff;padding:6px 14px;border-radius:999px;font-size:.85rem;font-weight:600;letter-spacing:.02em}\n.gpl-bottom .gpb-faq{margin:36px 0 8px}\n.gpl-bottom .gpb-faq h2{text-align:center;margin:0 0 20px;font-size:1.6rem}\n.gpl-bottom .gpb-tabs{border:1px solid #e8ebe2;border-radius:16px;overflow:hidden;background:#fff;box-shadow:0 2px 14px rgba(20,83,40,0.06)}\n.gpl-bottom .gpb-tablist{display:flex;gap:0;background:#f6f7f1;border-bottom:1px solid #e8ebe2;overflow-x:auto;scrollbar-width:none}\n.gpl-bottom .gpb-tablist::-webkit-scrollbar{display:none}\n.gpl-bottom .gpb-tab{flex:1 1 auto;min-width:max-content;background:transparent;border:0;padding:16px 22px;font-family:var(--wp--preset--font-family--poppins,\"Poppins\",sans-serif);font-size:.95rem;font-weight:600;color:#4c6066;cursor:pointer;border-bottom:3px solid transparent;transition:color .15s,border-color .15s,background .15s;white-space:nowrap;letter-spacing:.01em}\n.gpl-bottom .gpb-tab:hover{color:#145328;background:rgba(123,220,181,0.14)}\n.gpl-bottom .gpb-tab[aria-selected=\"true\"]{color:#145328;border-bottom-color:#15701e;background:#fff}\n.gpl-bottom .gpb-tab:focus{outline:2px solid #15701e;outline-offset:-2px}\n.gpl-bottom .gpb-panel{padding:28px 32px;display:none;animation:gpbfade .25s ease}\n.gpl-bottom .gpb-panel.is-active{display:block}\n@keyframes gpbfade{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}\n.gpl-bottom .gpb-panel p{margin:0;line-height:1.7;color:#243024;font-size:1rem}\n.gpl-bottom .gpb-panel p+p{margin-top:10px}\n.gpl-bottom .gpb-panel kbd,.gpl-bottom .gpb-panel code{background:#f0f3ec;padding:2px 6px;border-radius:4px;font-family:var(--wp--preset--font-family--dm-mono,\"DM Mono\",ui-monospace,monospace);font-size:.88em;color:#145328}\n.gpl-bottom .gpb-legend{margin:30px 0 8px}\n.gpl-bottom .gpb-legend h2{text-align:center;margin:0 0 6px;font-size:1.5rem}\n.gpl-bottom .gpb-legend-sub{text-align:center;color:#4c6066;font-size:.98rem;margin:0 auto 22px;max-width:640px}\n.gpl-bottom .gpb-legend-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:14px}\n.gpl-bottom .gpb-legend-item{display:flex;gap:14px;align-items:flex-start;background:#fff;border:1px solid #e8ebe2;border-radius:14px;padding:14px 16px;box-shadow:0 1px 4px rgba(20,83,40,0.04)}\n.gpl-bottom .gpb-swatch{flex:0 0 44px;width:44px;height:44px;border-radius:10px;border:1px solid rgba(20,83,40,0.12);position:relative;overflow:hidden;display:flex;align-items:center;justify-content:center}\n.gpl-bottom .gpb-swatch.s-elev{background:linear-gradient(135deg,#6a2dae 0%,#1a7951 30%,#e8d11a 55%,#f76a0c 80%,#c8232c 100%)}\n.gpl-bottom .gpb-swatch.s-boundary{background:#fbfcf6;border:2.5px solid #145328}\n.gpl-bottom .gpb-swatch.s-pass{background:repeating-linear-gradient(90deg,#f76a0c 0,#f76a0c 18px,transparent 18px,transparent 28px);background-color:#fbfcf6}\n.gpl-bottom .gpb-swatch.s-headland{background:linear-gradient(135deg,#fbfcf6 0%,#fbfcf6 38%,rgba(34,197,94,0.32) 38%,rgba(34,197,94,0.32) 100%);border:1.5px dashed #15701e}\n.gpl-bottom .gpb-swatch.s-uturn{background:#fbfcf6;color:#a21caf;font-size:22px;font-weight:700;font-family:var(--wp--preset--font-family--poppins,\"Poppins\",sans-serif);line-height:1}\n.gpl-bottom .gpb-swatch.s-uturn::before{content:\"\\21BB\"}\n.gpl-bottom .gpb-swatch.s-compaction{background:radial-gradient(circle at 50% 50%,rgba(162,28,175,0.42) 0%,rgba(162,28,175,0.18) 70%,transparent 100%),#fbfcf6}\n.gpl-bottom .gpb-swatch.s-swath{background:rgba(247,106,12,0.32);border:1px solid rgba(247,106,12,0.5)}\n.gpl-bottom .gpb-legend-body{flex:1;min-width:0}\n.gpl-bottom .gpb-legend-label{font-family:var(--wp--preset--font-family--poppins,\"Poppins\",sans-serif);font-weight:700;color:#145328;font-size:.98rem;margin:0 0 3px;line-height:1.25}\n.gpl-bottom .gpb-legend-desc{font-size:.88rem;color:#3a4a3a;line-height:1.45;margin:0}\n@media (max-width:760px){\n  .gpl-bottom h2{font-size:1.3rem}\n  .gpl-bottom .gpb-cta{padding:24px 22px}\n  .gpl-bottom .gpb-panel{padding:22px 20px}\n  .gpl-bottom .gpb-tab{padding:14px 16px;font-size:.9rem}\n  .gpl-bottom .gpb-legend-grid{grid-template-columns:1fr;gap:10px}\n  .gpl-bottom .gpb-legend-item{padding:12px 14px}\n  .gpl-bottom .gpb-swatch{flex:0 0 38px;width:38px;height:38px}\n}\n<\/style>\n<section class=\"gpb-cta\">\n  <h2>Bigger picture? GeoPard turns the data into prescriptions.<\/h2>\n  <p>This page plans paths on one field at a time. The full GeoPard platform combines multi-year yield, NDVI, soil tests, and real elevation into management zones, then generates variable-rate prescriptions and pushes them to your machine.<\/p>\n  <div class=\"gpb-integ-label\">Push prescriptions and pull as-applied data with:<\/div>\n  <div class=\"gpb-integ\">\n    <span class=\"gpb-integ-chip\">John Deere Operations Center<\/span>\n    <span class=\"gpb-integ-chip\">CNH FieldOps<\/span>\n    <span class=\"gpb-integ-chip\">AGCO \/ PTx FarmEngage<\/span>\n  <\/div>\n  <a class=\"gpb-btn\" href=\"https:\/\/app.geopard.tech\/sign-up?utm_source=geopard.tech&amp;utm_medium=lead-magnet&amp;utm_campaign=guidance-lines&amp;utm_content=below-tool-cta\">Register free in GeoPard &rarr;<\/a>\n<\/section>\n\n<section class=\"gpb-legend\" aria-labelledby=\"gpb-legend-h\">\n  <h2 id=\"gpb-legend-h\">What you're looking at on the map<\/h2>\n  <p class=\"gpb-legend-sub\">Seven layers stack on the canvas. Here is what each colour and symbol means agronomically.<\/p>\n  <div class=\"gpb-legend-grid\">\n    <div class=\"gpb-legend-item\">\n      <div class=\"gpb-swatch s-elev\" aria-hidden=\"true\"><\/div>\n      <div class=\"gpb-legend-body\">\n        <p class=\"gpb-legend-label\">Elevation low &rarr; high<\/p>\n        <p class=\"gpb-legend-desc\">Synthetic terrain heatmap, purple (low) to red (high). Drives the slope cost and which lines Topography follow chooses.<\/p>\n      <\/div>\n    <\/div>\n    <div class=\"gpb-legend-item\">\n      <div class=\"gpb-swatch s-boundary\" aria-hidden=\"true\"><\/div>\n      <div class=\"gpb-legend-body\">\n        <p class=\"gpb-legend-label\">Field boundary<\/p>\n        <p class=\"gpb-legend-desc\">Outer edge of the workable area. Sample fields or your uploaded GeoJSON \/ KML \/ shapefile polygon.<\/p>\n      <\/div>\n    <\/div>\n    <div class=\"gpb-legend-item\">\n      <div class=\"gpb-swatch s-pass\" aria-hidden=\"true\"><\/div>\n      <div class=\"gpb-legend-body\">\n        <p class=\"gpb-legend-label\">Guidance pass<\/p>\n        <p class=\"gpb-legend-desc\">Centerline of each implement pass. Spacing equals equipment width. The tractor steers down these lines.<\/p>\n      <\/div>\n    <\/div>\n    <div class=\"gpb-legend-item\">\n      <div class=\"gpb-swatch s-headland\" aria-hidden=\"true\"><\/div>\n      <div class=\"gpb-legend-body\">\n        <p class=\"gpb-legend-label\">Headland strip<\/p>\n        <p class=\"gpb-legend-desc\">Turnaround zone around the perimeter, usually 2&ndash;3 swath widths. Worked first by Boundary Follow, last by the others.<\/p>\n      <\/div>\n    <\/div>\n    <div class=\"gpb-legend-item\">\n      <div class=\"gpb-swatch s-uturn\" aria-hidden=\"true\"><\/div>\n      <div class=\"gpb-legend-body\">\n        <p class=\"gpb-legend-label\">U-turn \/ direction &rarr;<\/p>\n        <p class=\"gpb-legend-desc\">Turnaround arcs and the arrow showing drive direction along each pass. Fewer U-turns = less idle time and less fuel.<\/p>\n      <\/div>\n    <\/div>\n    <div class=\"gpb-legend-item\">\n      <div class=\"gpb-swatch s-compaction\" aria-hidden=\"true\"><\/div>\n      <div class=\"gpb-legend-body\">\n        <p class=\"gpb-legend-label\">Soil compaction zone<\/p>\n        <p class=\"gpb-legend-desc\">Where repeat turnarounds concentrate wheel passes. Compacted strips lose 5&ndash;15 % yield over time and are worth rotating.<\/p>\n      <\/div>\n    <\/div>\n    <div class=\"gpb-legend-item\">\n      <div class=\"gpb-swatch s-swath\" aria-hidden=\"true\"><\/div>\n      <div class=\"gpb-legend-body\">\n        <p class=\"gpb-legend-label\">Swath covered<\/p>\n        <p class=\"gpb-legend-desc\">Area actually worked by the implement (pass &times; equipment width). Drives the coverage % in the metrics panel.<\/p>\n      <\/div>\n    <\/div>\n  <\/div>\n<\/section>\n\n<section class=\"gpb-faq\" aria-labelledby=\"gpb-faq-h\">\n  <h2 id=\"gpb-faq-h\">Frequently asked questions<\/h2>\n  <div class=\"gpb-tabs\">\n    <div class=\"gpb-tablist\" role=\"tablist\" aria-label=\"Guidance Lines FAQ\">\n      <button class=\"gpb-tab\" role=\"tab\" id=\"gpb-tab-1\" aria-controls=\"gpb-panel-1\" aria-selected=\"true\" tabindex=\"0\">Approaches<\/button>\n      <button class=\"gpb-tab\" role=\"tab\" id=\"gpb-tab-2\" aria-controls=\"gpb-panel-2\" aria-selected=\"false\" tabindex=\"-1\">Slope &amp; fuel<\/button>\n      <button class=\"gpb-tab\" role=\"tab\" id=\"gpb-tab-3\" aria-controls=\"gpb-panel-3\" aria-selected=\"false\" tabindex=\"-1\">Coverage<\/button>\n      <button class=\"gpb-tab\" role=\"tab\" id=\"gpb-tab-4\" aria-controls=\"gpb-panel-4\" aria-selected=\"false\" tabindex=\"-1\">Upload formats<\/button>\n      <button class=\"gpb-tab\" role=\"tab\" id=\"gpb-tab-5\" aria-controls=\"gpb-panel-5\" aria-selected=\"false\" tabindex=\"-1\">Privacy &amp; data<\/button>\n      <button class=\"gpb-tab\" role=\"tab\" id=\"gpb-tab-6\" aria-controls=\"gpb-panel-6\" aria-selected=\"false\" tabindex=\"-1\">Integrations<\/button>\n    <\/div>\n    <div class=\"gpb-panel is-active\" role=\"tabpanel\" id=\"gpb-panel-1\" aria-labelledby=\"gpb-tab-1\">\n      <h3>What do the four approaches do?<\/h3>\n      <p><strong>AB Straight<\/strong> \u2014 parallel passes along the field's principal axis. The default for rectangular fields. Simplest to drive.<\/p>\n      <p><strong>AB Curve<\/strong> \u2014 gentle wave on each parallel pass. Useful when the field shape has irregular edges; the curve clips into corners better than a straight line.<\/p>\n      <p><strong>Boundary Follow<\/strong> \u2014 perimeter ring(s) inside the headland strip plus parallel body passes. Matches the real-world practice of working the headland first then filling the body.<\/p>\n      <p><strong>Topography follow (terrain)<\/strong> \u2014 passes that trace constant-elevation lines. The only approach that uses elevation data. Best on sloped fields where driving across contours burns 20\u201340 % more fuel than driving along them.<\/p>\n    <\/div>\n    <div class=\"gpb-panel\" role=\"tabpanel\" id=\"gpb-panel-2\" aria-labelledby=\"gpb-tab-2\" hidden>\n      <h3>How is the slope cost calculated?<\/h3>\n      <p>For each body pass, the tool samples elevation along the centerline every 4 m and measures the local <strong>grade<\/strong> (rise \/ run) along the drive direction. Mean squared grade \u00d7 12 = fuel-penalty multiplier. A 5 % sustained grade costs ~3 % extra fuel; a 15 % grade costs ~27 % extra \u2014 typical real-tractor figures.<\/p>\n      <p>Turnarounds get a fixed 1.25\u00d7 penalty per metre (engine load + hydraulics + gear changes). Both numbers show up as \"slope cost\" and \"turn fuel\" in the right-panel breakdown.<\/p>\n    <\/div>\n    <div class=\"gpb-panel\" role=\"tabpanel\" id=\"gpb-panel-3\" aria-labelledby=\"gpb-tab-3\" hidden>\n      <h3>Why does coverage show less than 100 %?<\/h3>\n      <p>The tool rasterises the field into <code>wM \/ 3<\/code> cells, marks cells inside the boundary, then checks each one against the nearest pass \/ ring center-line at <code>wM \/ 2<\/code> distance. <strong>Honest union area<\/strong> \u2014 no overlap double-counting.<\/p>\n      <p>Common reasons for &lt; 100 %: sharp boundary corners that the headland ring's inset can't reach; concave shapes (L-shape) where straight passes miss the second arm; curved boundaries (oval, pivot) where rounded corners under-fill. The fix is usually a different axis (try the slider), Boundary Follow, or splitting the field into sub-fields with different headings.<\/p>\n    <\/div>\n    <div class=\"gpb-panel\" role=\"tabpanel\" id=\"gpb-panel-4\" aria-labelledby=\"gpb-tab-4\" hidden>\n      <h3>What field formats can I upload?<\/h3>\n      <p><strong>GeoJSON<\/strong> (.geojson, .json) \u2014 Feature, FeatureCollection, or bare Polygon \/ MultiPolygon. Multi-feature files iterate via &lsaquo; \/ &rsaquo; arrows under the upload button.<\/p>\n      <p><strong>KML<\/strong> (.kml) \u2014 Placemark polygons. Multi-placemark files are also iterable.<\/p>\n      <p><strong>Shapefile zip<\/strong> (.zip) \u2014 standard ESRI shapefile, expects EPSG:4326 (WGS84). Projected shapefiles silently mis-place coordinates; reproject first or use the full GeoPard app which handles reprojection.<\/p>\n    <\/div>\n    <div class=\"gpb-panel\" role=\"tabpanel\" id=\"gpb-panel-5\" aria-labelledby=\"gpb-tab-5\" hidden>\n      <h3>Are you storing my field?<\/h3>\n      <p>No. The tool runs fully in your browser. Your field boundary is never uploaded anywhere \u2014 close the tab and it's gone. For long-term storage, multi-year analytics, prescriptions, and machine integrations, register a free GeoPard account.<\/p>\n    <\/div>\n    <div class=\"gpb-panel\" role=\"tabpanel\" id=\"gpb-panel-6\" aria-labelledby=\"gpb-tab-6\" hidden>\n      <h3>Can GeoPard push the plan to my machine?<\/h3>\n      <p>This tool is a planner \u2014 it doesn't write equipment files directly. The full GeoPard app exports variable-rate prescription files and imports as-applied data via <strong>John Deere Operations Center<\/strong>, <strong>CNH FieldOps<\/strong>, and <strong>AGCO \/ PTx FarmEngage<\/strong>. Shapefile and ISO-XML exports cover other monitors. Mixed-fleet farms welcome.<\/p>\n    <\/div>\n  <\/div>\n<\/section>\n<\/div>\n<script nowprocket data-no-optimize=\"1\" data-no-defer=\"1\" data-no-minify=\"1\">\n(function(){\n  var root = document.querySelector('.gpl-bottom .gpb-faq');\n  if(!root) return;\n  var tabs = root.querySelectorAll('.gpb-tab');\n  var panels = root.querySelectorAll('.gpb-panel');\n  function activate(idx){\n    tabs.forEach(function(t,i){\n      var on = i===idx;\n      t.setAttribute('aria-selected', on ? 'true' : 'false');\n      t.setAttribute('tabindex', on ? '0' : '-1');\n    });\n    panels.forEach(function(p,i){\n      var on = i===idx;\n      p.classList.toggle('is-active', on);\n      if(on){ p.removeAttribute('hidden'); } else { p.setAttribute('hidden',''); }\n    });\n  }\n  tabs.forEach(function(t,i){\n    t.addEventListener('click', function(){ activate(i); });\n    t.addEventListener('keydown', function(e){\n      var n=tabs.length, ni=i;\n      if(e.key==='ArrowRight'){ni=(i+1)%n;}\n      else if(e.key==='ArrowLeft'){ni=(i-1+n)%n;}\n      else if(e.key==='Home'){ni=0;}\n      else if(e.key==='End'){ni=n-1;}\n      else { return; }\n      e.preventDefault();\n      activate(ni);\n      tabs[ni].focus();\n    });\n  });\n})();\n<\/script>\n\n","protected":false},"excerpt":{"rendered":"<p>Fahrliniensimulator \u00b7 Kostenlos \u00b7 Keine Anmeldung \u00b7 5 Fahrpl\u00e4ne. W\u00e4hle den, der am meisten Kraftstoff spart. Fahrbahnbegrenzungs- und AB-Linien entfernen\u2026<\/p>","protected":false},"author":210157960,"featured_media":0,"parent":0,"menu_order":0,"comment_status":"closed","ping_status":"closed","template":"","meta":{"_coblocks_attr":"","_coblocks_dimensions":"","_coblocks_responsive_height":"","_coblocks_accordion_ie_support":"","_eb_attr":"","content-type":"","footnotes":"","big_sky_generated":false},"class_list":["post-13171","page","type-page","status-publish","hentry"],"acf":[],"yoast_head":"<!-- This site is optimized with the Yoast SEO plugin v27.9 - https:\/\/yoast.com\/product\/yoast-seo-wordpress\/ -->\n<title>Guidance Lines Simulator - GeoPard Agriculture<\/title>\n<meta name=\"robots\" content=\"index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1\" \/>\n<link rel=\"canonical\" href=\"https:\/\/geopard.tech\/de\/leitlinien\/\" \/>\n<meta property=\"og:locale\" content=\"de_DE\" \/>\n<meta property=\"og:type\" content=\"article\" \/>\n<meta property=\"og:title\" content=\"Guidance Lines Simulator - GeoPard Agriculture\" \/>\n<meta property=\"og:description\" content=\"Guidance Lines Simulator \u00b7 Free \u00b7 No signup 5 drive plans. Pick the one that saves the most fuel. Drop your boundary + AB lines...\" \/>\n<meta property=\"og:url\" content=\"https:\/\/geopard.tech\/de\/leitlinien\/\" \/>\n<meta property=\"og:site_name\" content=\"GeoPard - Precision agriculture Mapping software\" \/>\n<meta property=\"article:publisher\" content=\"https:\/\/www.facebook.com\/geopardAgriculture\/\" \/>\n<meta property=\"article:modified_time\" content=\"2026-06-15T11:42:58+00:00\" \/>\n<meta property=\"og:image\" content=\"https:\/\/i0.wp.com\/geopard.tech\/wp-content\/uploads\/2026\/03\/GeoPard-Background-Precision-Ag-software-Do-more-with-your-data.png?fit=3116%2C1754&ssl=1\" \/>\n\t<meta property=\"og:image:width\" content=\"3116\" \/>\n\t<meta property=\"og:image:height\" content=\"1754\" \/>\n\t<meta property=\"og:image:type\" content=\"image\/png\" \/>\n<meta name=\"twitter:card\" content=\"summary_large_image\" \/>\n<meta name=\"twitter:site\" content=\"@geopardagri\" \/>\n<meta name=\"twitter:label1\" content=\"Gesch\u00e4tzte Lesezeit\" \/>\n\t<meta name=\"twitter:data1\" content=\"4\u00a0Minuten\" \/>\n<script type=\"application\/ld+json\" class=\"yoast-schema-graph\">{\"@context\":\"https:\\\/\\\/schema.org\",\"@graph\":[{\"@type\":\"WebPage\",\"@id\":\"https:\\\/\\\/geopard.tech\\\/guidance-lines\\\/\",\"url\":\"https:\\\/\\\/geopard.tech\\\/guidance-lines\\\/\",\"name\":\"Guidance Lines Simulator - GeoPard Agriculture\",\"isPartOf\":{\"@id\":\"https:\\\/\\\/geopard.tech\\\/#website\"},\"datePublished\":\"2026-05-19T13:00:20+00:00\",\"dateModified\":\"2026-06-15T11:42:58+00:00\",\"breadcrumb\":{\"@id\":\"https:\\\/\\\/geopard.tech\\\/guidance-lines\\\/#breadcrumb\"},\"inLanguage\":\"de\",\"potentialAction\":[{\"@type\":\"ReadAction\",\"target\":[\"https:\\\/\\\/geopard.tech\\\/guidance-lines\\\/\"]}]},{\"@type\":\"BreadcrumbList\",\"@id\":\"https:\\\/\\\/geopard.tech\\\/guidance-lines\\\/#breadcrumb\",\"itemListElement\":[{\"@type\":\"ListItem\",\"position\":1,\"name\":\"Home\",\"item\":\"https:\\\/\\\/geopard.tech\\\/\"},{\"@type\":\"ListItem\",\"position\":2,\"name\":\"Guidance Lines Simulator\"}]},{\"@type\":\"WebSite\",\"@id\":\"https:\\\/\\\/geopard.tech\\\/#website\",\"url\":\"https:\\\/\\\/geopard.tech\\\/\",\"name\":\"GeoPard - Precision agriculture software\",\"description\":\"Precision agriculture Mapping software\",\"publisher\":{\"@id\":\"https:\\\/\\\/geopard.tech\\\/#organization\"},\"alternateName\":\"GeoPard\",\"potentialAction\":[{\"@type\":\"SearchAction\",\"target\":{\"@type\":\"EntryPoint\",\"urlTemplate\":\"https:\\\/\\\/geopard.tech\\\/?s={search_term_string}\"},\"query-input\":{\"@type\":\"PropertyValueSpecification\",\"valueRequired\":true,\"valueName\":\"search_term_string\"}}],\"inLanguage\":\"de\"},{\"@type\":\"Organization\",\"@id\":\"https:\\\/\\\/geopard.tech\\\/#organization\",\"name\":\"GeoPard Agriculture\",\"alternateName\":\"GeoPard\",\"url\":\"https:\\\/\\\/geopard.tech\\\/\",\"logo\":{\"@type\":\"ImageObject\",\"inLanguage\":\"de\",\"@id\":\"https:\\\/\\\/geopard.tech\\\/#\\\/schema\\\/logo\\\/image\\\/\",\"url\":\"https:\\\/\\\/i0.wp.com\\\/geopard.tech\\\/wp-content\\\/uploads\\\/2022\\\/03\\\/favicon.png?fit=200%2C200&ssl=1\",\"contentUrl\":\"https:\\\/\\\/i0.wp.com\\\/geopard.tech\\\/wp-content\\\/uploads\\\/2022\\\/03\\\/favicon.png?fit=200%2C200&ssl=1\",\"width\":200,\"height\":200,\"caption\":\"GeoPard Agriculture\"},\"image\":{\"@id\":\"https:\\\/\\\/geopard.tech\\\/#\\\/schema\\\/logo\\\/image\\\/\"},\"sameAs\":[\"https:\\\/\\\/www.facebook.com\\\/geopardAgriculture\\\/\",\"https:\\\/\\\/x.com\\\/geopardagri\",\"https:\\\/\\\/www.linkedin.com\\\/company\\\/geopard-agriculture\\\/\",\"https:\\\/\\\/www.instagram.com\\\/geopardagriculture\\\/\"]}]}<\/script>\n<!-- \/ Yoast SEO plugin. -->","yoast_head_json":{"title":"Leitliniensimulator \u2013 GeoPard Landwirtschaft","robots":{"index":"index","follow":"follow","max-snippet":"max-snippet:-1","max-image-preview":"max-image-preview:large","max-video-preview":"max-video-preview:-1"},"canonical":"https:\/\/geopard.tech\/de\/leitlinien\/","og_locale":"de_DE","og_type":"article","og_title":"Guidance Lines Simulator - GeoPard Agriculture","og_description":"Guidance Lines Simulator \u00b7 Free \u00b7 No signup 5 drive plans. Pick the one that saves the most fuel. Drop your boundary + AB lines...","og_url":"https:\/\/geopard.tech\/de\/leitlinien\/","og_site_name":"GeoPard - Precision agriculture Mapping software","article_publisher":"https:\/\/www.facebook.com\/geopardAgriculture\/","article_modified_time":"2026-06-15T11:42:58+00:00","og_image":[{"width":3116,"height":1754,"url":"https:\/\/i0.wp.com\/geopard.tech\/wp-content\/uploads\/2026\/03\/GeoPard-Background-Precision-Ag-software-Do-more-with-your-data.png?fit=3116%2C1754&ssl=1","type":"image\/png"}],"twitter_card":"summary_large_image","twitter_site":"@geopardagri","twitter_misc":{"Gesch\u00e4tzte Lesezeit":"4\u00a0Minuten"},"schema":{"@context":"https:\/\/schema.org","@graph":[{"@type":"WebPage","@id":"https:\/\/geopard.tech\/guidance-lines\/","url":"https:\/\/geopard.tech\/guidance-lines\/","name":"Leitliniensimulator \u2013 GeoPard Landwirtschaft","isPartOf":{"@id":"https:\/\/geopard.tech\/#website"},"datePublished":"2026-05-19T13:00:20+00:00","dateModified":"2026-06-15T11:42:58+00:00","breadcrumb":{"@id":"https:\/\/geopard.tech\/guidance-lines\/#breadcrumb"},"inLanguage":"de","potentialAction":[{"@type":"ReadAction","target":["https:\/\/geopard.tech\/guidance-lines\/"]}]},{"@type":"BreadcrumbList","@id":"https:\/\/geopard.tech\/guidance-lines\/#breadcrumb","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https:\/\/geopard.tech\/"},{"@type":"ListItem","position":2,"name":"Guidance Lines Simulator"}]},{"@type":"WebSite","@id":"https:\/\/geopard.tech\/#website","url":"https:\/\/geopard.tech\/","name":"GeoPard - Pr\u00e4zisionslandwirtschaftssoftware","description":"Pr\u00e4zisionslandwirtschaft Kartierungssoftware","publisher":{"@id":"https:\/\/geopard.tech\/#organization"},"alternateName":"GeoPard","potentialAction":[{"@type":"SearchAction","target":{"@type":"EntryPoint","urlTemplate":"https:\/\/geopard.tech\/?s={search_term_string}"},"query-input":{"@type":"PropertyValueSpecification","valueRequired":true,"valueName":"search_term_string"}}],"inLanguage":"de"},{"@type":"Organization","@id":"https:\/\/geopard.tech\/#organization","name":"GeoPard Landwirtschaft","alternateName":"GeoPard","url":"https:\/\/geopard.tech\/","logo":{"@type":"ImageObject","inLanguage":"de","@id":"https:\/\/geopard.tech\/#\/schema\/logo\/image\/","url":"https:\/\/i0.wp.com\/geopard.tech\/wp-content\/uploads\/2022\/03\/favicon.png?fit=200%2C200&ssl=1","contentUrl":"https:\/\/i0.wp.com\/geopard.tech\/wp-content\/uploads\/2022\/03\/favicon.png?fit=200%2C200&ssl=1","width":200,"height":200,"caption":"GeoPard Agriculture"},"image":{"@id":"https:\/\/geopard.tech\/#\/schema\/logo\/image\/"},"sameAs":["https:\/\/www.facebook.com\/geopardAgriculture\/","https:\/\/x.com\/geopardagri","https:\/\/www.linkedin.com\/company\/geopard-agriculture\/","https:\/\/www.instagram.com\/geopardagriculture\/"]}]}},"jetpack_likes_enabled":true,"jetpack_sharing_enabled":true,"jetpack_shortlink":"https:\/\/wp.me\/PdiCPa-3qr","_links":{"self":[{"href":"https:\/\/geopard.tech\/de\/wp-json\/wp\/v2\/pages\/13171","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/geopard.tech\/de\/wp-json\/wp\/v2\/pages"}],"about":[{"href":"https:\/\/geopard.tech\/de\/wp-json\/wp\/v2\/types\/page"}],"author":[{"embeddable":true,"href":"https:\/\/geopard.tech\/de\/wp-json\/wp\/v2\/users\/210157960"}],"replies":[{"embeddable":true,"href":"https:\/\/geopard.tech\/de\/wp-json\/wp\/v2\/comments?post=13171"}],"version-history":[{"count":0,"href":"https:\/\/geopard.tech\/de\/wp-json\/wp\/v2\/pages\/13171\/revisions"}],"wp:attachment":[{"href":"https:\/\/geopard.tech\/de\/wp-json\/wp\/v2\/media?parent=13171"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}