Download website/simulation.html from sasa7676/LeafGuard: direct link, hf CLI and curl.
- Browser
- Download file 66.3 kB
-
https://huggingface.co/spaces/sasa7676/LeafGuard/resolve/main/website/simulation.html
- Command line
-
hf download hf://spaces/sasa7676/LeafGuard/website/simulation.html
-
curl -L -o simulation.html https://huggingface.co/spaces/sasa7676/LeafGuard/resolve/main/website/simulation.html
66.3 kB
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width,initial-scale=1.0"> | |
| <title>LeafGuard Field Simulation</title> | |
| <style> | |
| *{margin:0;padding:0;box-sizing:border-box} | |
| html,body{width:100%;height:100%;background:#020a02;overflow:hidden} | |
| #simulation-mount{position:relative;width:100%;height:100vh;overflow:hidden;background:#020a02;font-family:'Courier New',monospace} | |
| #agro-canvas{position:absolute;top:0;left:0;width:100%;height:100%;display:block} | |
| .hud{position:absolute;z-index:10;pointer-events:none} | |
| .hud-interactive{pointer-events:all} | |
| /* TOP BAR */ | |
| #hud-top{top:0;left:0;right:0;height:48px;background:rgba(0,0,0,0.72);border-bottom:1px solid rgba(80,160,80,0.25);display:flex;align-items:center;padding:0 14px;gap:18px} | |
| #hud-brand{color:#4a9;font-size:11px;font-weight:700;letter-spacing:2px;text-transform:uppercase;white-space:nowrap} | |
| #hud-simtime{color:#8ec;font-size:12px;white-space:nowrap} | |
| #hud-weather-badge{background:rgba(60,120,60,0.3);border:1px solid rgba(80,180,80,0.3);border-radius:4px;padding:3px 8px;color:#aed;font-size:11px;white-space:nowrap} | |
| #hud-stats-right{margin-left:auto;display:flex;gap:12px;align-items:center;font-size:11px} | |
| .stat{color:#6a9;white-space:nowrap} | |
| .stat span{color:#cec} | |
| .stat.warn span{color:#fa8} | |
| .stat.crit span{color:#f55} | |
| /* TOP-LEFT LEGEND */ | |
| #hud-legend{top:56px;left:10px;width:200px;background:rgba(0,10,0,0.8);border:1px solid rgba(60,140,60,0.3);border-radius:6px;padding:10px;font-size:11px;color:#8ac} | |
| #hud-legend .leg-title{color:#4c9;font-size:10px;letter-spacing:1px;text-transform:uppercase;margin-bottom:6px} | |
| .leg-row{display:flex;align-items:center;gap:6px;margin:3px 0} | |
| .leg-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0} | |
| .leg-cond{margin-top:8px;padding-top:6px;border-top:1px solid rgba(60,120,60,0.2);font-size:12px;color:#ce9} | |
| #hud-gps{margin-top:6px;font-size:10px;color:#678;line-height:1.6} | |
| #hud-updated{font-size:9px;color:#456;margin-top:3px} | |
| /* RIGHT PANEL */ | |
| #hud-right{top:56px;right:10px;width:248px;display:flex;flex-direction:column;gap:8px} | |
| .hud-card{background:rgba(0,10,0,0.82);border:1px solid rgba(60,140,60,0.28);border-radius:6px;padding:10px;font-size:11px;color:#8ac} | |
| .hud-card .card-title{color:#4c9;font-size:10px;letter-spacing:1px;text-transform:uppercase;margin-bottom:7px} | |
| .plant-row{display:flex;align-items:center;gap:5px;padding:2px 0;border-bottom:1px solid rgba(60,100,60,0.15)} | |
| .plant-row:last-child{border-bottom:none} | |
| .p-dot{width:7px;height:7px;border-radius:50%;flex-shrink:0} | |
| .p-name{color:#ace;flex:1;font-size:10px} | |
| .p-bars{font-size:10px;color:#6a9} | |
| .p-badge{font-size:9px;padding:1px 5px;border-radius:3px;font-weight:700} | |
| .badge-ok{background:rgba(40,180,80,0.25);color:#4e9} | |
| .badge-stress{background:rgba(200,160,40,0.25);color:#da6} | |
| .badge-disease{background:rgba(200,40,40,0.25);color:#f55} | |
| .badge-treated{background:rgba(40,120,200,0.25);color:#5ae} | |
| .env-grid{display:grid;grid-template-columns:1fr 1fr;gap:4px} | |
| .env-item{background:rgba(0,20,0,0.5);border-radius:3px;padding:4px 6px} | |
| .env-label{font-size:9px;color:#567;text-transform:uppercase;letter-spacing:0.5px} | |
| .env-val{font-size:12px;color:#cec;font-weight:600;margin-top:1px} | |
| .field-grid{display:grid;grid-template-columns:1fr 1fr;gap:4px} | |
| .field-item{background:rgba(0,20,0,0.5);border-radius:3px;padding:4px 6px;text-align:center} | |
| .field-label{font-size:9px;color:#567;text-transform:uppercase} | |
| .field-val{font-size:13px;color:#aec;font-weight:700} | |
| .product-row{font-size:10px;color:#789;padding:2px 0;display:flex;justify-content:space-between} | |
| /* BOTTOM CONTROLS */ | |
| #hud-bottom{bottom:36px;left:0;right:0;padding:0 12px;display:flex;align-items:center;gap:10px;height:44px;background:rgba(0,0,0,0.7);border-top:1px solid rgba(60,140,60,0.2)} | |
| .btn-group{display:flex;gap:4px} | |
| .btn{background:rgba(30,80,30,0.6);border:1px solid rgba(80,160,80,0.3);color:#8c9;font-family:inherit;font-size:11px;padding:5px 10px;border-radius:4px;cursor:pointer;transition:all 0.15s} | |
| .btn:hover{background:rgba(50,130,50,0.7);color:#cec;border-color:rgba(80,200,80,0.5)} | |
| .btn.active{background:rgba(40,180,80,0.35);color:#4e9;border-color:#4a7} | |
| .btn-plant{font-size:10px;padding:4px 8px} | |
| .btn-action{font-size:10px;padding:4px 8px;background:rgba(0,40,60,0.6);border-color:rgba(40,120,160,0.3);color:#6ae} | |
| .btn-action:hover{background:rgba(0,60,100,0.7);color:#aef} | |
| .btn-action.water-active{background:rgba(0,60,180,0.4);color:#4af;border-color:#48f} | |
| .btn-action.spray-active{background:rgba(60,180,80,0.35);color:#4e9;border-color:#4a7} | |
| .btn-speed{font-size:10px;padding:4px 6px} | |
| .sep{color:rgba(80,160,80,0.3);font-size:18px;user-select:none} | |
| #hud-bottom-hint{margin-left:auto;font-size:10px;color:#456;white-space:nowrap} | |
| /* STATUS BAR */ | |
| #hud-status{bottom:0;left:0;right:0;height:32px;background:rgba(0,0,0,0.8);border-top:1px solid rgba(40,100,40,0.2);display:flex;align-items:center;padding:0 14px;justify-content:space-between} | |
| #status-hints{font-size:10px;color:#456} | |
| #status-right{font-size:10px;color:#567} | |
| /* MODE INDICATOR */ | |
| #mode-indicator{top:56px;left:50%;transform:translateX(-50%);background:rgba(0,20,40,0.9);border:1px solid #4af;border-radius:20px;padding:6px 18px;color:#8cf;font-size:12px;display:none;letter-spacing:1px} | |
| /* PLANT TOOLTIP */ | |
| #plant-tooltip{background:rgba(0,15,0,0.95);border:1px solid rgba(80,200,80,0.4);border-radius:8px;padding:12px;font-size:11px;color:#aec;width:200px;display:none;pointer-events:none} | |
| #plant-tooltip .tt-title{color:#4e9;font-size:13px;font-weight:700;margin-bottom:6px} | |
| #plant-tooltip .tt-row{display:flex;justify-content:space-between;padding:2px 0;border-bottom:1px solid rgba(40,80,40,0.2)} | |
| #plant-tooltip .tt-row:last-child{border-bottom:none} | |
| #plant-tooltip .tt-label{color:#678} | |
| #plant-tooltip .tt-val{color:#cec} | |
| /* STORM WARNING */ | |
| #storm-warning{top:55px;left:50%;transform:translateX(-50%);background:rgba(180,60,0,0.9);border:1px solid #f80;border-radius:6px;padding:6px 16px;color:#fca;font-size:12px;font-weight:700;display:none;animation:pulse 1s infinite} | |
| @keyframes pulse{0%,100%{opacity:1}50%{opacity:0.6}} | |
| /* SCROLLBAR hidden */ | |
| ::-webkit-scrollbar{display:none} | |
| </style> | |
| </head> | |
| <body> | |
| <div id="simulation-mount"> | |
| <canvas id="agro-canvas"></canvas> | |
| <!-- TOP BAR --> | |
| <div class="hud" id="hud-top"> | |
| <span id="hud-brand">LeafGuard</span> | |
| <span id="hud-simtime">Day 1 Β· Week 1 Β· Spring Β· 06:00</span> | |
| <span id="hud-weather-badge">π Loadingβ¦</span> | |
| <div id="hud-stats-right"> | |
| <span class="stat">PLANTS <span id="s-plants">9</span></span> | |
| <span class="stat">FPS <span id="s-fps">--</span></span> | |
| <span class="stat" id="s-temp-stat">TEMP <span id="s-temp">--Β°C</span></span> | |
| <span class="stat">WIND <span id="s-wind">--</span></span> | |
| <span class="stat">RAIN <span id="s-rain">OFF</span></span> | |
| <span class="stat">HUM <span id="s-hum">--%</span></span> | |
| <span class="stat">SOIL <span id="s-soil">--%</span></span> | |
| </div> | |
| </div> | |
| <!-- LEGEND --> | |
| <div class="hud" id="hud-legend"> | |
| <div class="leg-title">Field Legend</div> | |
| <div class="leg-row"><div class="leg-dot" style="background:#4e9"></div><span>Healthy</span></div> | |
| <div class="leg-row"><div class="leg-dot" style="background:#da6"></div><span>Stressed</span></div> | |
| <div class="leg-row"><div class="leg-dot" style="background:#f55"></div><span>Diseased</span></div> | |
| <div class="leg-row"><div class="leg-dot" style="background:#5ae"></div><span>Treated</span></div> | |
| <div class="leg-cond" id="leg-cond">CLEAR</div> | |
| <div id="hud-gps">GPS: 24.7136Β°N 46.6753Β°E<br>Zone: Arid/Semi-arid</div> | |
| <div id="hud-updated">Last update: --</div> | |
| </div> | |
| <!-- STORM WARNING --> | |
| <div class="hud" id="storm-warning">β STORM WARNING β Wind >18 m/s</div> | |
| <!-- MODE INDICATOR --> | |
| <div class="hud" id="mode-indicator">WATER MODE β click a plant to irrigate</div> | |
| <!-- RIGHT PANEL --> | |
| <div class="hud" id="hud-right"> | |
| <div class="hud-card"> | |
| <div class="card-title">Plant Status</div> | |
| <div id="plant-list"></div> | |
| </div> | |
| <div class="hud-card"> | |
| <div class="card-title">Environment</div> | |
| <div class="env-grid"> | |
| <div class="env-item"><div class="env-label">Sun Elevation</div><div class="env-val" id="e-sun">--Β°</div></div> | |
| <div class="env-item"><div class="env-label">Cloud Cover</div><div class="env-val" id="e-cloud">--%</div></div> | |
| <div class="env-item"><div class="env-label">Photosynthesis</div><div class="env-val" id="e-photo">--%</div></div> | |
| <div class="env-item"><div class="env-label">Evaporation</div><div class="env-val" id="e-evap">--/hr</div></div> | |
| </div> | |
| </div> | |
| <div class="hud-card"> | |
| <div class="card-title">Field Metrics</div> | |
| <div class="field-grid"> | |
| <div class="field-item"><div class="field-label">Soil Moisture</div><div class="field-val" id="f-moist">--%</div></div> | |
| <div class="field-item"><div class="field-label">Soil pH</div><div class="field-val" id="f-ph">6.8</div></div> | |
| <div class="field-item"><div class="field-label">Avg Temp</div><div class="field-val" id="f-temp">--Β°C</div></div> | |
| <div class="field-item"><div class="field-label">N-P-K Est</div><div class="field-val" id="f-npk">--</div></div> | |
| </div> | |
| </div> | |
| <div class="hud-card"> | |
| <div class="card-title">Applied Products</div> | |
| <div id="product-list"><div style="color:#456;font-size:10px">None applied yet</div></div> | |
| </div> | |
| </div> | |
| <!-- BOTTOM CONTROLS --> | |
| <div class="hud hud-interactive" id="hud-bottom"> | |
| <div class="btn-group"> | |
| <button class="btn btn-plant active" id="btn-tomato" onclick="window.simSetPlant('tomato')">Tomato</button> | |
| <button class="btn btn-plant" id="btn-wheat" onclick="window.simSetPlant('wheat')">Wheat</button> | |
| <button class="btn btn-plant" id="btn-pepper" onclick="window.simSetPlant('pepper')">Pepper</button> | |
| <button class="btn btn-plant" id="btn-cucumber" onclick="window.simSetPlant('cucumber')">Cucumber</button> | |
| </div> | |
| <span class="sep">|</span> | |
| <div class="btn-group"> | |
| <button class="btn btn-action" id="btn-water" onclick="window.simToggleWater()">π§ Water</button> | |
| <button class="btn btn-action" id="btn-spray" onclick="window.simToggleSpray()">πΏ Spray</button> | |
| <button class="btn btn-action" onclick="window.simAddDisease()">π¦ Disease</button> | |
| <button class="btn btn-action" onclick="window.simAdvanceDay()">+1 Day</button> | |
| <button class="btn btn-action" onclick="window.simAdvanceWeek()">+1 Week</button> | |
| </div> | |
| <span class="sep">|</span> | |
| <div class="btn-group"> | |
| <button class="btn btn-speed active" id="spd-1" onclick="window.simSetSpeed(1)">x1</button> | |
| <button class="btn btn-speed" id="spd-6" onclick="window.simSetSpeed(6)">x6</button> | |
| <button class="btn btn-speed" id="spd-24" onclick="window.simSetSpeed(24)">x24</button> | |
| <button class="btn btn-speed" id="spd-168" onclick="window.simSetSpeed(168)">x168</button> | |
| </div> | |
| <span id="hud-bottom-hint">Drag=rotate | Right drag=pan | Scroll=zoom | Click=interact</span> | |
| </div> | |
| <!-- STATUS BAR --> | |
| <div class="hud" id="hud-status"> | |
| <span id="status-hints">Drag=rotate Β· Right drag=pan Β· Scroll=zoom Β· Click plant=inspect</span> | |
| <span id="status-right" id="status-r">GPS -- Β· -- Β· v1.0</span> | |
| </div> | |
| <!-- PLANT TOOLTIP --> | |
| <div class="hud" id="plant-tooltip"> | |
| <div class="tt-title" id="tt-title">Plant 1</div> | |
| <div class="tt-row"><span class="tt-label">Age</span><span class="tt-val" id="tt-age">--</span></div> | |
| <div class="tt-row"><span class="tt-label">Health</span><span class="tt-val" id="tt-health">--</span></div> | |
| <div class="tt-row"><span class="tt-label">Water</span><span class="tt-val" id="tt-water">--</span></div> | |
| <div class="tt-row"><span class="tt-label">Growth</span><span class="tt-val" id="tt-growth">--</span></div> | |
| <div class="tt-row"><span class="tt-label">Fruits</span><span class="tt-val" id="tt-fruits">--</span></div> | |
| <div class="tt-row"><span class="tt-label">Status</span><span class="tt-val" id="tt-status">--</span></div> | |
| <div class="tt-row"><span class="tt-label">Stressors</span><span class="tt-val" id="tt-stress">--</span></div> | |
| <div class="tt-row"><span class="tt-label">Est. Harvest</span><span class="tt-val" id="tt-harvest">--</span></div> | |
| </div> | |
| </div> | |
| <script> | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // AGROMIND WEBGL FIELD SIMULATION β Pure WebGL 1.0 | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| (function(){ | |
| ; | |
| // ββ 1. MATRIX MATH ββββββββββββββββββββββββββββββββββββββββββββββ | |
| function m4id(){const m=new Float32Array(16);m[0]=m[5]=m[10]=m[15]=1;return m;} | |
| function m4mul(a,b){ | |
| const c=new Float32Array(16); | |
| for(let r=0;r<4;r++)for(let col=0;col<4;col++){ | |
| let s=0;for(let k=0;k<4;k++)s+=a[k*4+r]*b[col*4+k]; | |
| c[col*4+r]=s; | |
| } | |
| return c; | |
| } | |
| function m4persp(fov,asp,n,f){ | |
| const t=1/Math.tan(fov/2),nf=1/(n-f),m=new Float32Array(16); | |
| m[0]=t/asp;m[5]=t;m[10]=(f+n)*nf;m[11]=-1;m[14]=2*f*n*nf;return m; | |
| } | |
| function m4look(ex,ey,ez,cx,cy,cz){ | |
| let fx=cx-ex,fy=cy-ey,fz=cz-ez; | |
| const fl=Math.sqrt(fx*fx+fy*fy+fz*fz);fx/=fl;fy/=fl;fz/=fl; | |
| let rx=-fz,ry=0,rz=fx; // cross(f,(0,1,0)) | |
| const rl=Math.sqrt(rx*rx+rz*rz);rx/=rl;rz/=rl; | |
| const ux=ry*fz-rz*fy,uy=rz*fx-rx*fz,uz=rx*fy-ry*fx; | |
| const m=new Float32Array(16); | |
| m[0]=rx;m[4]=ry;m[8]=rz; m[12]=-(rx*ex+ry*ey+rz*ez); | |
| m[1]=ux;m[5]=uy;m[9]=uz; m[13]=-(ux*ex+uy*ey+uz*ez); | |
| m[2]=-fx;m[6]=-fy;m[10]=-fz;m[14]=fx*ex+fy*ey+fz*ez; | |
| m[15]=1;return m; | |
| } | |
| function m4tr(tx,ty,tz){const m=m4id();m[12]=tx;m[13]=ty;m[14]=tz;return m;} | |
| function m4sc(sx,sy,sz){const m=new Float32Array(16);m[0]=sx;m[5]=sy;m[10]=sz;m[15]=1;return m;} | |
| function m4ry(a){const m=m4id(),c=Math.cos(a),s=Math.sin(a);m[0]=c;m[2]=-s;m[8]=s;m[10]=c;return m;} | |
| function m4rz(a){const m=m4id(),c=Math.cos(a),s=Math.sin(a);m[0]=c;m[1]=s;m[4]=-s;m[5]=c;return m;} | |
| function m4rx(a){const m=m4id(),c=Math.cos(a),s=Math.sin(a);m[5]=c;m[6]=s;m[9]=-s;m[10]=c;return m;} | |
| // ββ 2. GEOMETRY BUILDERS ββββββββββββββββββββββββββββββββββββββββ | |
| // stride=9 floats: [px,py,pz, nx,ny,nz, cr,cg,cb] | |
| function mkBox(w,h,d,r,g,b){ | |
| const hw=w/2,hh=h/2,hd=d/2; | |
| const F=[ | |
| {vs:[[-hw,-hh,hd],[hw,-hh,hd],[hw,hh,hd],[-hw,hh,hd]],n:[0,0,1]}, | |
| {vs:[[hw,-hh,-hd],[-hw,-hh,-hd],[-hw,hh,-hd],[hw,hh,-hd]],n:[0,0,-1]}, | |
| {vs:[[-hw,-hh,-hd],[-hw,-hh,hd],[-hw,hh,hd],[-hw,hh,-hd]],n:[-1,0,0]}, | |
| {vs:[[hw,-hh,hd],[hw,-hh,-hd],[hw,hh,-hd],[hw,hh,hd]],n:[1,0,0]}, | |
| {vs:[[-hw,hh,hd],[hw,hh,hd],[hw,hh,-hd],[-hw,hh,-hd]],n:[0,1,0]}, | |
| {vs:[[-hw,-hh,-hd],[hw,-hh,-hd],[hw,-hh,hd],[-hw,-hh,hd]],n:[0,-1,0]}, | |
| ]; | |
| const V=[],I=[];let vi=0; | |
| for(const f of F){for(const p of f.vs)V.push(p[0],p[1],p[2],f.n[0],f.n[1],f.n[2],r,g,b);I.push(vi,vi+1,vi+2,vi,vi+2,vi+3);vi+=4;} | |
| return{verts:new Float32Array(V),idx:new Uint16Array(I)}; | |
| } | |
| function mkCyl(radius,height,segs,r,g,b){ | |
| const V=[],I=[];let vi=0;const hh=height*.5,PI2=Math.PI*2; | |
| for(let i=0;i<segs;i++){ | |
| const a0=(i/segs)*PI2,a1=((i+1)/segs)*PI2; | |
| const x0=Math.cos(a0)*radius,z0=Math.sin(a0)*radius,x1=Math.cos(a1)*radius,z1=Math.sin(a1)*radius; | |
| V.push(x0,-hh,z0,Math.cos(a0),0,Math.sin(a0),r,g,b); | |
| V.push(x1,-hh,z1,Math.cos(a1),0,Math.sin(a1),r,g,b); | |
| V.push(x1, hh,z1,Math.cos(a1),0,Math.sin(a1),r,g,b); | |
| V.push(x0, hh,z0,Math.cos(a0),0,Math.sin(a0),r,g,b); | |
| I.push(vi,vi+1,vi+2,vi,vi+2,vi+3);vi+=4; | |
| } | |
| const tc=vi++;V.push(0,hh,0,0,1,0,r,g,b);const ts=vi; | |
| for(let i=0;i<=segs;i++){const a=(i/segs)*PI2;V.push(Math.cos(a)*radius,hh,Math.sin(a)*radius,0,1,0,r,g,b);if(i>0)I.push(tc,ts+i-1,ts+i);vi++;} | |
| const bc=vi++;V.push(0,-hh,0,0,-1,0,r,g,b);const bs=vi; | |
| for(let i=0;i<=segs;i++){const a=(i/segs)*PI2;V.push(Math.cos(a)*radius,-hh,Math.sin(a)*radius,0,-1,0,r,g,b);if(i>0)I.push(bc,bs+i,bs+i-1);vi++;} | |
| return{verts:new Float32Array(V),idx:new Uint16Array(I)}; | |
| } | |
| function mkSph(radius,segs,r,g,b){ | |
| const V=[],I=[],lons=segs*2; | |
| for(let lat=0;lat<=segs;lat++){ | |
| const th=(lat/segs)*Math.PI,st=Math.sin(th),ct=Math.cos(th); | |
| for(let lon=0;lon<=lons;lon++){ | |
| const ph=(lon/lons)*Math.PI*2,nx=Math.cos(ph)*st,ny=ct,nz=Math.sin(ph)*st; | |
| V.push(nx*radius,ny*radius,nz*radius,nx,ny,nz,r,g,b); | |
| } | |
| } | |
| const cols=lons+1; | |
| for(let lat=0;lat<segs;lat++)for(let lon=0;lon<lons;lon++){ | |
| const a=lat*cols+lon,bv=a+cols; | |
| I.push(a,bv,a+1,bv,bv+1,a+1); | |
| } | |
| return{verts:new Float32Array(V),idx:new Uint16Array(I)}; | |
| } | |
| function mkCone(radius,height,segs,r,g,b){ | |
| const V=[],I=[];let vi=0;const hh=height*.5,PI2=Math.PI*2; | |
| const sL=Math.sqrt(radius*radius+height*height),ny=radius/sL,ns=height/sL; | |
| for(let i=0;i<segs;i++){ | |
| const a0=(i/segs)*PI2,a1=((i+1)/segs)*PI2; | |
| const x0=Math.cos(a0)*radius,z0=Math.sin(a0)*radius,x1=Math.cos(a1)*radius,z1=Math.sin(a1)*radius; | |
| const am=(a0+a1)/2; | |
| V.push(0,hh,0,Math.cos(am)*ns,ny,Math.sin(am)*ns,r,g,b); | |
| V.push(x0,-hh,z0,Math.cos(a0)*ns,ny,Math.sin(a0)*ns,r,g,b); | |
| V.push(x1,-hh,z1,Math.cos(a1)*ns,ny,Math.sin(a1)*ns,r,g,b); | |
| I.push(vi,vi+1,vi+2);vi+=3; | |
| } | |
| const bc=vi++;V.push(0,-hh,0,0,-1,0,r,g,b);const bs=vi; | |
| for(let i=0;i<=segs;i++){const a=(i/segs)*PI2;V.push(Math.cos(a)*radius,-hh,Math.sin(a)*radius,0,-1,0,r,g,b);if(i>0)I.push(bc,bs+i,bs+i-1);vi++;} | |
| return{verts:new Float32Array(V),idx:new Uint16Array(I)}; | |
| } | |
| // Combine multiple meshes (optionally with transform applied to verts in JS) | |
| function combineMeshes(parts){ | |
| let totalV=0,totalI=0; | |
| for(const p of parts){totalV+=p.mesh.verts.length;totalI+=p.mesh.idx.length;} | |
| const V=new Float32Array(totalV),I=new Uint16Array(totalI); | |
| let vo=0,io=0,vBase=0; | |
| for(const p of parts){ | |
| const mv=p.mesh.verts,mi=p.mesh.idx; | |
| const tx=p.tx||0,ty=p.ty||0,tz=p.tz||0; | |
| const sx=p.sx||1,sy=p.sy||1,sz=p.sz||1; | |
| for(let i=0;i<mv.length;i+=9){ | |
| V[vo+0]=mv[i+0]*sx+tx; V[vo+1]=mv[i+1]*sy+ty; V[vo+2]=mv[i+2]*sz+tz; | |
| V[vo+3]=mv[i+3]; V[vo+4]=mv[i+4]; V[vo+5]=mv[i+5]; | |
| V[vo+6]=mv[i+6]; V[vo+7]=mv[i+7]; V[vo+8]=mv[i+8]; | |
| vo+=9; | |
| } | |
| const vCount=mv.length/9; | |
| for(let i=0;i<mi.length;i++)I[io++]=mi[i]+vBase; | |
| vBase+=vCount; | |
| } | |
| return{verts:V,idx:I}; | |
| } | |
| // ββ 3. WEBGL SETUP ββββββββββββββββββββββββββββββββββββββββββββββ | |
| const mount=document.getElementById('simulation-mount'); | |
| const canvas=document.getElementById('agro-canvas'); | |
| const gl=canvas.getContext('webgl',{antialias:true,alpha:false})||canvas.getContext('experimental-webgl'); | |
| if(!gl){mount.innerHTML='<div style="color:#f55;padding:40px;text-align:center">WebGL not supported</div>';return;} | |
| gl.enable(gl.DEPTH_TEST);gl.enable(gl.BLEND); | |
| gl.blendFunc(gl.SRC_ALPHA,gl.ONE_MINUS_SRC_ALPHA); | |
| gl.enable(gl.CULL_FACE);gl.cullFace(gl.BACK); | |
| function resizeCanvas(){ | |
| const pr=window.devicePixelRatio||1; | |
| canvas.width=mount.clientWidth*pr;canvas.height=mount.clientHeight*pr; | |
| gl.viewport(0,0,canvas.width,canvas.height); | |
| } | |
| resizeCanvas(); | |
| window.addEventListener('resize',resizeCanvas); | |
| // ββ 4. SHADERS ββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const SOLID_VS=` | |
| attribute vec3 a_pos; | |
| attribute vec3 a_norm; | |
| attribute vec3 a_col; | |
| uniform mat4 u_mvp; | |
| uniform mat4 u_model; | |
| uniform vec3 u_lightDir; | |
| uniform vec3 u_lightCol; | |
| uniform float u_ambient; | |
| uniform vec3 u_eyePos; | |
| uniform float u_fogNear; | |
| uniform float u_fogFar; | |
| varying vec3 v_litCol; | |
| varying float v_fog; | |
| void main(){ | |
| vec4 wPos=u_model*vec4(a_pos,1.0); | |
| gl_Position=u_mvp*vec4(a_pos,1.0); | |
| vec3 wNorm=normalize(mat3(u_model)*a_norm); | |
| float diff=max(dot(wNorm,normalize(u_lightDir)),0.0); | |
| vec3 lit=a_col*(u_ambient+diff*(1.0-u_ambient))*u_lightCol; | |
| v_litCol=clamp(lit,0.0,1.0); | |
| float dist=length(wPos.xyz-u_eyePos); | |
| v_fog=clamp((u_fogFar-dist)/(u_fogFar-u_fogNear),0.0,1.0); | |
| }`; | |
| const SOLID_FS=` | |
| precision mediump float; | |
| varying vec3 v_litCol; | |
| varying float v_fog; | |
| uniform vec3 u_fogCol; | |
| uniform vec3 u_tint; | |
| uniform float u_alpha; | |
| void main(){ | |
| vec3 c=v_litCol*u_tint; | |
| gl_FragColor=vec4(mix(u_fogCol,c,v_fog),u_alpha); | |
| }`; | |
| const LINE_VS=` | |
| attribute vec3 a_pos; | |
| attribute vec4 a_col; | |
| uniform mat4 u_mvp; | |
| varying vec4 v_col; | |
| void main(){gl_Position=u_mvp*vec4(a_pos,1.0);v_col=a_col;}`; | |
| const LINE_FS=` | |
| precision mediump float; | |
| varying vec4 v_col; | |
| void main(){gl_FragColor=v_col;}`; | |
| function compileShader(src,type){ | |
| const sh=gl.createShader(type);gl.shaderSource(sh,src);gl.compileShader(sh); | |
| if(!gl.getShaderParameter(sh,gl.COMPILE_STATUS)){console.error(gl.getShaderInfoLog(sh));return null;} | |
| return sh; | |
| } | |
| function makeProgram(vs,fs){ | |
| const p=gl.createProgram(); | |
| gl.attachShader(p,compileShader(vs,gl.VERTEX_SHADER)); | |
| gl.attachShader(p,compileShader(fs,gl.FRAGMENT_SHADER)); | |
| gl.linkProgram(p); | |
| if(!gl.getProgramParameter(p,gl.LINK_STATUS)){console.error(gl.getProgramInfoLog(p));return null;} | |
| return p; | |
| } | |
| const progSolid=makeProgram(SOLID_VS,SOLID_FS); | |
| const progLine=makeProgram(LINE_VS,LINE_FS); | |
| // Solid program locations | |
| const S={ | |
| aPos:gl.getAttribLocation(progSolid,'a_pos'), | |
| aNorm:gl.getAttribLocation(progSolid,'a_norm'), | |
| aCol:gl.getAttribLocation(progSolid,'a_col'), | |
| uMvp:gl.getUniformLocation(progSolid,'u_mvp'), | |
| uModel:gl.getUniformLocation(progSolid,'u_model'), | |
| uLightDir:gl.getUniformLocation(progSolid,'u_lightDir'), | |
| uLightCol:gl.getUniformLocation(progSolid,'u_lightCol'), | |
| uAmbient:gl.getUniformLocation(progSolid,'u_ambient'), | |
| uEyePos:gl.getUniformLocation(progSolid,'u_eyePos'), | |
| uFogNear:gl.getUniformLocation(progSolid,'u_fogNear'), | |
| uFogFar:gl.getUniformLocation(progSolid,'u_fogFar'), | |
| uFogCol:gl.getUniformLocation(progSolid,'u_fogCol'), | |
| uTint:gl.getUniformLocation(progSolid,'u_tint'), | |
| uAlpha:gl.getUniformLocation(progSolid,'u_alpha'), | |
| }; | |
| const L={ | |
| aPos:gl.getAttribLocation(progLine,'a_pos'), | |
| aCol:gl.getAttribLocation(progLine,'a_col'), | |
| uMvp:gl.getUniformLocation(progLine,'u_mvp'), | |
| }; | |
| // ββ 5. BUFFER HELPERS βββββββββββββββββββββββββββββββββββββββββββ | |
| function uploadMesh(mesh){ | |
| const vbo=gl.createBuffer(),ibo=gl.createBuffer(); | |
| gl.bindBuffer(gl.ARRAY_BUFFER,vbo);gl.bufferData(gl.ARRAY_BUFFER,mesh.verts,gl.STATIC_DRAW); | |
| gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER,ibo);gl.bufferData(gl.ELEMENT_ARRAY_BUFFER,mesh.idx,gl.STATIC_DRAW); | |
| return{vbo,ibo,count:mesh.idx.length}; | |
| } | |
| function drawMesh(buf,mvp,model,tint,alpha){ | |
| gl.useProgram(progSolid); | |
| gl.bindBuffer(gl.ARRAY_BUFFER,buf.vbo); | |
| gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER,buf.ibo); | |
| gl.vertexAttribPointer(S.aPos,3,gl.FLOAT,false,36,0);gl.enableVertexAttribArray(S.aPos); | |
| gl.vertexAttribPointer(S.aNorm,3,gl.FLOAT,false,36,12);gl.enableVertexAttribArray(S.aNorm); | |
| gl.vertexAttribPointer(S.aCol,3,gl.FLOAT,false,36,24);gl.enableVertexAttribArray(S.aCol); | |
| gl.uniformMatrix4fv(S.uMvp,false,mvp); | |
| gl.uniformMatrix4fv(S.uModel,false,model); | |
| gl.uniform3fv(S.uTint,tint||[1,1,1]); | |
| gl.uniform1f(S.uAlpha,alpha!=null?alpha:1); | |
| gl.drawElements(gl.TRIANGLES,buf.count,gl.UNSIGNED_SHORT,0); | |
| } | |
| // ββ 6. PLANT MESHES βββββββββββββββββββββββββββββββββββββββββββββ | |
| // Each plant type: array of {buf, ty, tx, tz, sx, sy, sz, isFruit, fruitIdx} | |
| // Parts built in local plant space (base at y=0) | |
| function buildPlantMeshes(){ | |
| const types={}; | |
| // TOMATO | |
| types.tomato={ | |
| parts:[ | |
| {buf:uploadMesh(mkCyl(0.06,0.6,8,0.55,0.35,0.2)),ty:0.3,tx:0,tz:0}, | |
| {buf:uploadMesh(mkSph(0.44,10,0.2,0.65,0.15)),ty:0.78,tx:0,tz:0}, | |
| {buf:uploadMesh(mkSph(0.28,8,0.15,0.7,0.12)),ty:1.0,tx:0.1,tz:0.05}, | |
| ], | |
| fruitRipe:uploadMesh(mkSph(0.08,6,0.85,0.15,0.1)), | |
| fruitUnripe:uploadMesh(mkSph(0.08,6,0.2,0.6,0.1)), | |
| fruitPos:[[0.32,0.7,-0.18],[-0.28,0.65,0.12],[0.1,0.62,0.36],[-0.12,0.8,0.28],[0.38,0.73,0.14]], | |
| swayFreq:0.8,maxFruits:5,fruitWeek:6, | |
| }; | |
| // WHEAT β 4 stalks | |
| const wStalks=[]; | |
| const wOff=[[-0.15,0,-0.15],[0.15,0,-0.15],[-0.15,0,0.15],[0.15,0,0.15]]; | |
| for(const [ox,oy,oz] of wOff){ | |
| wStalks.push({buf:uploadMesh(mkCyl(0.025,0.9,5,0.7,0.65,0.2)),ty:0.45+oy,tx:ox,tz:oz}); | |
| wStalks.push({buf:uploadMesh(mkCone(0.04,0.18,5,0.8,0.7,0.15)),ty:0.99+oy,tx:ox,tz:oz}); | |
| } | |
| types.wheat={parts:wStalks,swayFreq:1.4,maxFruits:0,fruitWeek:999}; | |
| // PEPPER | |
| types.pepper={ | |
| parts:[ | |
| {buf:uploadMesh(mkCyl(0.04,0.5,7,0.4,0.25,0.1)),ty:0.25,tx:0,tz:0}, | |
| {buf:uploadMesh(mkSph(0.35,9,0.15,0.55,0.1)),ty:0.62,tx:0,tz:0}, | |
| ], | |
| fruitRipe:uploadMesh(mkCyl(0.035,0.12,5,0.85,0.1,0.1)), | |
| fruitUnripe:uploadMesh(mkCyl(0.035,0.12,5,0.2,0.65,0.1)), | |
| fruitPos:[[0.2,0.55,-0.1],[-0.22,0.52,0.14],[0.1,0.6,0.22],[-0.1,0.58,-0.2],[0.26,0.58,0.18]], | |
| fruitRot:true, // fruits rotate down | |
| swayFreq:0.8,maxFruits:5,fruitWeek:5, | |
| }; | |
| // CUCUMBER | |
| types.cucumber={ | |
| parts:[ | |
| {buf:uploadMesh(mkCyl(0.035,0.8,7,0.35,0.25,0.12)),ty:0.4,tx:0,tz:0}, | |
| {buf:uploadMesh(mkSph(0.22,7,0.15,0.55,0.1)),ty:0.3,tx:0.16,tz:0.05,sy:0.25}, | |
| {buf:uploadMesh(mkSph(0.24,7,0.15,0.55,0.1)),ty:0.55,tx:-0.14,tz:0.1,sy:0.25}, | |
| {buf:uploadMesh(mkSph(0.2,7,0.15,0.55,0.1)),ty:0.77,tx:0.12,tz:-0.1,sy:0.25}, | |
| ], | |
| fruitRipe:uploadMesh(mkCyl(0.04,0.24,5,0.2,0.65,0.15)), | |
| fruitPos:[[0.25,0.42,0.06],[-0.24,0.62,-0.05],[0.2,0.63,0.2]], | |
| fruitRot:'x', // horizontal | |
| swayFreq:1.0,maxFruits:3,fruitWeek:5, | |
| }; | |
| return types; | |
| } | |
| let PLANTS_MESH=buildPlantMeshes(); | |
| // ββ 7. SCENE STATIC MESHES ββββββββββββββββββββββββββββββββββββββ | |
| // Ground (3 soil layers, each plot as a flat box) | |
| const GROUND_LAYER=uploadMesh(mkBox(10,0.08,10,0.23,0.12,0.06)); // topsoil template | |
| const GROUND_SUB=uploadMesh(mkBox(10,0.3,10,0.18,0.09,0.04)); | |
| const GROUND_BED=uploadMesh(mkBox(10,0.25,10,0.06,0.03,0.08)); | |
| const SUN_MESH=uploadMesh(mkSph(0.6,8,1,0.98,0.88,0.4)); | |
| const MOON_MESH=uploadMesh(mkSph(0.35,6,0.9,0.9,0.88)); | |
| const CLOUD_MESH=uploadMesh(combineMeshes([ | |
| {mesh:mkSph(1,7,0.9,0.9,0.92),tx:0,ty:0,tz:0}, | |
| {mesh:mkSph(0.7,6,0.9,0.9,0.92),tx:0.9,ty:-0.1,tz:0}, | |
| {mesh:mkSph(0.8,6,0.9,0.9,0.92),tx:-0.8,ty:-0.15,tz:0}, | |
| ])); | |
| const STAR_MESH=uploadMesh(mkSph(0.04,4,0.95,0.92,0.88)); | |
| const PUDDLE_MESH=uploadMesh(mkBox(1.2,0.01,0.9,0.1,0.2,0.45)); | |
| // ββ 8. DYNAMIC BUFFERS (lines + particles) ββββββββββββββββββββββ | |
| function makeDynBuf(size){ | |
| const vbo=gl.createBuffer(); | |
| gl.bindBuffer(gl.ARRAY_BUFFER,vbo); | |
| gl.bufferData(gl.ARRAY_BUFFER,size*4,gl.DYNAMIC_DRAW); | |
| return{vbo,size,data:new Float32Array(size)}; | |
| } | |
| const DYN_LINE=makeDynBuf(24000); // line vertex pairs (7 floats per vert) | |
| const DYN_PART=makeDynBuf(6000); // point particles (7 floats per vert) | |
| let dynLineCount=0,dynPartCount=0; | |
| function updateDynBuf(db,data,count){ | |
| gl.bindBuffer(gl.ARRAY_BUFFER,db.vbo); | |
| gl.bufferSubData(gl.ARRAY_BUFFER,0,data.subarray(0,count*7)); | |
| } | |
| function drawLines(buf,count,mvp){ | |
| if(count===0)return; | |
| gl.useProgram(progLine); | |
| gl.bindBuffer(gl.ARRAY_BUFFER,buf.vbo); | |
| gl.vertexAttribPointer(L.aPos,3,gl.FLOAT,false,28,0);gl.enableVertexAttribArray(L.aPos); | |
| gl.vertexAttribPointer(L.aCol,4,gl.FLOAT,false,28,12);gl.enableVertexAttribArray(L.aCol); | |
| gl.uniformMatrix4fv(L.uMvp,false,mvp); | |
| gl.drawArrays(gl.LINES,0,count); | |
| } | |
| function drawPoints(buf,count,mvp){ | |
| if(count===0)return; | |
| gl.useProgram(progLine); | |
| gl.bindBuffer(gl.ARRAY_BUFFER,buf.vbo); | |
| gl.vertexAttribPointer(L.aPos,3,gl.FLOAT,false,28,0);gl.enableVertexAttribArray(L.aPos); | |
| gl.vertexAttribPointer(L.aCol,4,gl.FLOAT,false,28,12);gl.enableVertexAttribArray(L.aCol); | |
| gl.uniformMatrix4fv(L.uMvp,false,mvp); | |
| gl.drawArrays(gl.POINTS,0,count); | |
| } | |
| // Line/particle push helpers | |
| function pushLine(d,off,x0,y0,z0,x1,y1,z1,r,g,b,a){ | |
| d[off+0]=x0;d[off+1]=y0;d[off+2]=z0;d[off+3]=r;d[off+4]=g;d[off+5]=b;d[off+6]=a; | |
| d[off+7]=x1;d[off+8]=y1;d[off+9]=z1;d[off+10]=r;d[off+11]=g;d[off+12]=b;d[off+13]=a; | |
| return off+14; | |
| } | |
| function pushVert(d,off,x,y,z,r,g,b,a){ | |
| d[off+0]=x;d[off+1]=y;d[off+2]=z;d[off+3]=r;d[off+4]=g;d[off+5]=b;d[off+6]=a; | |
| return off+7; | |
| } | |
| // ββ 9. CAMERA βββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const cam={ | |
| theta:0.5,phi:0.55,dist:14, | |
| tx:0,ty:0.5,tz:0, // target | |
| tTheta:0.5,tPhi:0.55,tDist:14, | |
| tTx:0,tTy:0.5,tTz:0, | |
| mouse:{down:false,rdown:false,lx:0,ly:0}, | |
| }; | |
| function camEye(){ | |
| const cp=Math.cos(cam.phi),sp=Math.sin(cam.phi); | |
| const ct=Math.cos(cam.theta),st=Math.sin(cam.theta); | |
| return[cam.tx+cam.dist*cp*st, cam.ty+cam.dist*sp, cam.tz+cam.dist*cp*ct]; | |
| } | |
| function lerpCam(){ | |
| const L=0.08; | |
| cam.theta+=(cam.tTheta-cam.theta)*L; cam.phi+=(cam.tPhi-cam.phi)*L; | |
| cam.dist+=(cam.tDist-cam.dist)*L; | |
| cam.tx+=(cam.tTx-cam.tx)*L; cam.ty+=(cam.tTy-cam.ty)*L; cam.tz+=(cam.tTz-cam.tz)*L; | |
| cam.tPhi=Math.max(0.05,Math.min(Math.PI/2-0.05,cam.tPhi)); | |
| cam.tDist=Math.max(3,Math.min(40,cam.tDist)); | |
| } | |
| canvas.addEventListener('mousedown',e=>{ | |
| if(e.button===0)cam.mouse.down=true; | |
| else if(e.button===2)cam.mouse.rdown=true; | |
| cam.mouse.lx=e.clientX;cam.mouse.ly=e.clientY; | |
| }); | |
| canvas.addEventListener('contextmenu',e=>e.preventDefault()); | |
| window.addEventListener('mouseup',()=>{cam.mouse.down=false;cam.mouse.rdown=false;}); | |
| window.addEventListener('mousemove',e=>{ | |
| const dx=e.clientX-cam.mouse.lx,dy=e.clientY-cam.mouse.ly; | |
| cam.mouse.lx=e.clientX;cam.mouse.ly=e.clientY; | |
| if(cam.mouse.down){cam.tTheta-=dx*0.006;cam.tPhi-=dy*0.006;} | |
| else if(cam.mouse.rdown){ | |
| const cp=Math.cos(cam.theta),sp_=Math.sin(cam.theta); | |
| const f=cam.dist*0.003; | |
| cam.tTx+=(-cp*dx+0)*f; cam.tTy+=dy*f*0.5; cam.tTz+=(sp_*dx)*f; | |
| } | |
| }); | |
| canvas.addEventListener('wheel',e=>{cam.tDist+=e.deltaY*0.02;e.preventDefault();},{passive:false}); | |
| // Touch support | |
| let lastTouch=null,lastTouchDist=0; | |
| canvas.addEventListener('touchstart',e=>{ | |
| e.preventDefault(); | |
| if(e.touches.length===1){cam.mouse.down=true;cam.mouse.lx=e.touches[0].clientX;cam.mouse.ly=e.touches[0].clientY;} | |
| if(e.touches.length===2){lastTouchDist=Math.hypot(e.touches[0].clientX-e.touches[1].clientX,e.touches[0].clientY-e.touches[1].clientY);} | |
| },{passive:false}); | |
| canvas.addEventListener('touchend',()=>{cam.mouse.down=false;}); | |
| canvas.addEventListener('touchmove',e=>{ | |
| e.preventDefault(); | |
| if(e.touches.length===1&&cam.mouse.down){ | |
| const dx=e.touches[0].clientX-cam.mouse.lx,dy=e.touches[0].clientY-cam.mouse.ly; | |
| cam.mouse.lx=e.touches[0].clientX;cam.mouse.ly=e.touches[0].clientY; | |
| cam.tTheta-=dx*0.006;cam.tPhi-=dy*0.006; | |
| } | |
| if(e.touches.length===2){ | |
| const d=Math.hypot(e.touches[0].clientX-e.touches[1].clientX,e.touches[0].clientY-e.touches[1].clientY); | |
| cam.tDist-=(d-lastTouchDist)*0.05;lastTouchDist=d; | |
| } | |
| },{passive:false}); | |
| // ββ 10. SIMULATION STATE βββββββββββββββββββββββββββββββββββββββββ | |
| const SS={ // global sim state | |
| simDay:1,simHour:6,simSeason:'Spring',simSpeed:1, | |
| latitude:24.7136,longitude:46.6753,locationName:'Riyadh, SA',climateZone:'Arid', | |
| temperature:34,humidity:18,windSpeed:3.2,windDirection:45, | |
| windDirectionCardinal:'NE',precipitation:0,isRaining:false,rainIntensity:0, | |
| cloudCover:0.1,weatherCondition:'CLEAR',lastWeatherUpdate:null, | |
| sunElevation:45,sunAzimuth:180,isDaytime:true,photosynthesisRate:0.8, | |
| soilMoisture:60,soilPH:6.8,evaporationRate:0.12, | |
| plants:[],avgHealth:0,avgWater:0,yieldEst:0, | |
| appliedProducts:[],plantType:'tomato', | |
| waterModeActive:false,sprayModeActive:false, | |
| }; | |
| window.simState=SS; | |
| // Init 9 plants in 3x3 grid | |
| for(let i=0;i<9;i++){ | |
| SS.plants.push({ | |
| id:i,health:80+Math.random()*20,water:65+Math.random()*20, | |
| growth:0.15+Math.random()*0.3,status:'healthy',diseased:false,treated:false, | |
| treatStartDay:0,treatProgress:0, | |
| fruits:0,age:Math.floor(Math.random()*14)+1, | |
| x:(i%3-1)*3,y:0,z:(Math.floor(i/3)-1)*3, | |
| sprayParticles:[],waterParticles:[],photoGlow:0, | |
| }); | |
| } | |
| // Stars (static positions) | |
| const STARS=[]; | |
| for(let i=0;i<200;i++){ | |
| const th=Math.random()*Math.PI*2,ph=Math.random()*Math.PI*0.5+0.1; | |
| const d=28; | |
| STARS.push([Math.cos(ph)*Math.sin(th)*d, Math.sin(ph)*d, Math.cos(ph)*Math.cos(th)*d]); | |
| } | |
| // Clouds | |
| const CLOUDS=[]; | |
| for(let i=0;i<10;i++){ | |
| CLOUDS.push({ | |
| x:(Math.random()-0.5)*20,y:9+Math.random()*3,z:(Math.random()-0.5)*20, | |
| scale:0.5+Math.random()*0.7,speed:0.3+Math.random()*0.4, | |
| }); | |
| } | |
| // Rain particles | |
| const RAIN=[]; | |
| for(let i=0;i<200;i++){ | |
| RAIN.push({x:(Math.random()-0.5)*18,y:Math.random()*10,z:(Math.random()-0.5)*18,vy:0}); | |
| } | |
| // Wind streaks | |
| const WIND_STREAKS=[]; | |
| for(let i=0;i<50;i++){ | |
| WIND_STREAKS.push({x:(Math.random()-0.5)*18,y:0.5+Math.random()*4,z:(Math.random()-0.5)*18,t:Math.random()*10,alpha:0}); | |
| } | |
| // Evaporation | |
| const EVAP=[]; | |
| for(let p of SS.plants){ | |
| for(let j=0;j<4;j++)EVAP.push({px:p.x,pz:p.z,x:p.x+(Math.random()-.5)*2,y:0,z:p.z+(Math.random()-.5)*2,vy:0.3+Math.random()*0.3,life:Math.random()}); | |
| } | |
| // ββ 11. PHYSICS & SIMULATION ββββββββββββββββββββββββββββββββββββ | |
| let lastPhysicsHour=-1; | |
| function updatePhysics(dt,simTimeHours){ | |
| const T=SS.temperature,M=SS.soilMoisture,W=SS.windSpeed; | |
| // Evaporation | |
| const evaRate=0.01*T*(1-SS.humidity/100); | |
| SS.evaporationRate=evaRate; | |
| SS.soilMoisture=Math.max(0,SS.soilMoisture-evaRate*dt*0.5); | |
| // Rain moisture | |
| if(SS.isRaining){ | |
| SS.soilMoisture=Math.min(100,SS.soilMoisture+SS.rainIntensity*0.8*dt); | |
| } | |
| // Sun & photosynthesis | |
| const sunEl=SS.sunElevation*Math.PI/180; | |
| SS.photosynthesisRate=Math.max(0,Math.sin(sunEl)); | |
| SS.isDaytime=SS.sunElevation>-5; | |
| // Per-plant updates (continuous, scaled) | |
| for(const p of SS.plants){ | |
| // Water consumption | |
| p.water=Math.max(0,p.water-(0.5+T*0.02+W*0.01)*dt/3600*SS.simSpeed); | |
| if(SS.isRaining)p.water=Math.min(100,p.water+SS.rainIntensity*1.2*dt/3600*SS.simSpeed); | |
| // Health delta (per sim-hour, scaled) | |
| let dh=0; | |
| if(M>30&&M<80)dh+=0.5; if(T>18&&T<30)dh+=0.3; if(SS.photosynthesisRate>0.5)dh+=0.2; | |
| if(M<20)dh-=1.0; if(M<10)dh-=3.0; | |
| if(T>42)dh-=3.0; if(T<5)dh-=2.0; | |
| if(T>38&&T<=42)dh-=1.0; else if(T>30&&T<=38)dh-=0.05; | |
| // Disease: full damage if untreated, tapers off over 2 days as treatment takes hold | |
| if(p.diseased){ | |
| if(!p.treated){dh-=0.8;} | |
| else{ | |
| const daysTreating=SS.simDay-p.treatStartDay+(SS.simHour/24); | |
| const suppress=Math.min(1,daysTreating/2); // fully suppressed by day 2 | |
| dh-=0.8*(1-suppress); | |
| } | |
| } | |
| // Gradual recovery while treated (+0.4/sim-hr, builds up over first day) | |
| if(p.treated){ | |
| const daysTreating=SS.simDay-p.treatStartDay+(SS.simHour/24); | |
| dh+=0.4*Math.min(1,daysTreating); | |
| } | |
| if(W>18)dh-=0.5; | |
| if(p.water<25||M<20)dh-=1.0; | |
| p.health=Math.max(0,Math.min(100,p.health+dh*dt/3600*SS.simSpeed)); | |
| // Growth | |
| if(SS.sunElevation>0&&p.health>40){ | |
| let gr=0.002*(p.health/100)*SS.photosynthesisRate; | |
| if(T>18&&T<30)gr*=1.2; | |
| p.growth=Math.min(1,p.growth+gr*dt/3600*SS.simSpeed); | |
| } | |
| // Fruit production | |
| const MESH=PLANTS_MESH[SS.plantType]; | |
| const maxF=MESH?MESH.maxFruits:0; | |
| if(maxF>0&&p.growth>0.5&&p.health>60){ | |
| const fc=0.001*dt/3600*SS.simSpeed; | |
| if(Math.random()<fc)p.fruits=Math.min(maxF,p.fruits+1); | |
| } | |
| // Age | |
| p.age+=dt/3600/24*SS.simSpeed; | |
| // Treatment progress & cure after 4 sim-days | |
| if(p.treated){ | |
| const daysTreating=SS.simDay-p.treatStartDay+(SS.simHour/24); | |
| p.treatProgress=Math.min(1,daysTreating/4); | |
| if(daysTreating>=4){p.treated=false;p.diseased=false;p.treatProgress=0;} | |
| } | |
| // Status | |
| if(p.treated)p.status='treated'; | |
| else if(p.diseased)p.status='diseased'; | |
| else if(p.water<25||M<20)p.status='stressed'; | |
| else if(p.health>70)p.status='healthy'; | |
| else p.status='poor'; | |
| // Photo glow pulse | |
| p.photoGlow=SS.photosynthesisRate*(0.5+0.5*Math.sin(Date.now()*0.002+p.id)); | |
| } | |
| // Aggregates | |
| SS.avgHealth=SS.plants.reduce((a,p)=>a+p.health,0)/9; | |
| SS.avgWater=SS.plants.reduce((a,p)=>a+p.water,0)/9; | |
| // Sim time | |
| SS.simHour+=SS.simSpeed*dt/3600; | |
| if(SS.simHour>=24){SS.simHour-=24;SS.simDay++;} | |
| if(SS.simDay>90){SS.simDay=1;SS.simSeason=(['Spring','Summer','Autumn','Winter'][((['Spring','Summer','Autumn','Winter'].indexOf(SS.simSeason)+1)%4)]);} | |
| } | |
| // ββ 12. SOLAR POSITION ββββββββββββββββββββββββββββββββββββββββββ | |
| function calcSun(lat,lng,dayOfYear,hour){ | |
| const latR=lat*Math.PI/180; | |
| const dec=23.45*Math.sin((284+dayOfYear)/365*2*Math.PI)*Math.PI/180; | |
| const ha=(hour-12)*15*Math.PI/180; | |
| const sinEl=Math.sin(latR)*Math.sin(dec)+Math.cos(latR)*Math.cos(dec)*Math.cos(ha); | |
| const el=Math.asin(Math.max(-1,Math.min(1,sinEl))); | |
| const cosAz=(Math.sin(dec)*Math.cos(latR)-Math.cos(dec)*Math.cos(ha)*Math.sin(latR))/Math.cos(el+0.001); | |
| let az=Math.acos(Math.max(-1,Math.min(1,cosAz))); | |
| if(Math.sin(ha)>0)az=Math.PI*2-az; | |
| return{el:el*180/Math.PI,az:az*180/Math.PI,elR:el,azR:az}; | |
| } | |
| function sunWorldPos(el,az){ | |
| const dist=22,elR=el*Math.PI/180,azR=az*Math.PI/180; | |
| return[dist*Math.cos(elR)*Math.sin(azR),dist*Math.sin(elR),dist*Math.cos(elR)*Math.cos(azR)]; | |
| } | |
| // ββ 13. WEATHER API βββββββββββββββββββββββββββββββββββββββββββββ | |
| function fetchWeather(){ | |
| const{latitude:lat,longitude:lng}=SS; | |
| const url=`https://api.open-meteo.com/v1/forecast?latitude=${lat.toFixed(4)}&longitude=${lng.toFixed(4)}¤t=temperature_2m,relative_humidity_2m,wind_speed_10m,wind_direction_10m,precipitation,weather_code&timezone=auto`; | |
| fetch(url).then(r=>r.json()).then(data=>{ | |
| const c=data.current; | |
| SS.temperature=c.temperature_2m; | |
| SS.humidity=c.relative_humidity_2m; | |
| SS.windSpeed=c.wind_speed_10m/3.6; | |
| SS.windDirection=c.wind_direction_10m; | |
| SS.precipitation=c.precipitation; | |
| const wc=c.weather_code; | |
| if(wc<=1){SS.cloudCover=0.05;SS.isRaining=false;SS.weatherCondition='CLEAR';} | |
| else if(wc<=3){SS.cloudCover=0.4;SS.isRaining=false;SS.weatherCondition='CLOUDY';} | |
| else if(wc<=48){SS.cloudCover=0.8;SS.isRaining=false;SS.weatherCondition='FOG';} | |
| else if(wc<=67){SS.isRaining=true;SS.rainIntensity=wc<60?0.2:0.6;SS.cloudCover=0.7;SS.weatherCondition='RAIN';} | |
| else if(wc<=82){SS.isRaining=true;SS.rainIntensity=1.0;SS.cloudCover=0.9;SS.weatherCondition='RAIN';} | |
| else if(wc>=95){SS.isRaining=true;SS.rainIntensity=1.0;SS.windSpeed+=8;SS.cloudCover=0.95;SS.weatherCondition='STORM';} | |
| else{SS.isRaining=false;SS.weatherCondition='CLEAR';} | |
| if(c.precipitation>0&&!SS.isRaining){SS.isRaining=true;SS.rainIntensity=Math.min(1,c.precipitation/10);} | |
| SS.lastWeatherUpdate=new Date().toLocaleTimeString(); | |
| updateWindCardinal(); | |
| }).catch(()=>{SS.lastWeatherUpdate='API unavailable';}); | |
| } | |
| function updateWindCardinal(){ | |
| const dirs=['N','NE','E','SE','S','SW','W','NW']; | |
| SS.windDirectionCardinal=dirs[Math.round(SS.windDirection/45)%8]; | |
| } | |
| function deriveClimate(){ | |
| const la=Math.abs(SS.latitude); | |
| if(la<15){SS.climateZone='Tropical';SS.humidity=75;SS.windDirection=120;} | |
| else if(la<30){SS.climateZone='Arid';SS.humidity=18;SS.windDirection=45;} | |
| else if(la<45){SS.climateZone='Mediterranean';SS.humidity=55;SS.windDirection=270;} | |
| else if(la<65){SS.climateZone='Temperate';SS.humidity=65;SS.windDirection=225;} | |
| else{SS.climateZone='Subarctic';SS.humidity=45;SS.windDirection=270;} | |
| updateWindCardinal(); | |
| } | |
| // ββ 14. GEOLOCATION βββββββββββββββββββββββββββββββββββββββββββββ | |
| function initLocation(){ | |
| if(navigator.geolocation){ | |
| navigator.geolocation.getCurrentPosition(pos=>{ | |
| SS.latitude=pos.coords.latitude;SS.longitude=pos.coords.longitude; | |
| SS.locationName='Your Location'; | |
| deriveClimate();fetchWeather();updateGPSDisplay(); | |
| },()=>{deriveClimate();fetchWeather();updateGPSDisplay();}); | |
| }else{deriveClimate();fetchWeather();updateGPSDisplay();} | |
| } | |
| function updateGPSDisplay(){ | |
| document.getElementById('hud-gps').innerHTML= | |
| `GPS: ${SS.latitude.toFixed(4)}Β°N ${SS.longitude.toFixed(4)}Β°E<br>Zone: ${SS.climateZone}`; | |
| document.getElementById('status-right').textContent= | |
| `${SS.latitude.toFixed(2)}Β°N ${SS.longitude.toFixed(2)}Β°E Β· ${SS.climateZone} Β· v1.0`; | |
| } | |
| setInterval(fetchWeather,600000); // every 10 min | |
| // ββ 15. SKY COLOR ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function skyColor(sunElev){ | |
| const e=sunElev; | |
| if(e<-10)return[0.01,0.04,0.01]; | |
| if(e<0){const t=(e+10)/10;return lerp3([0.01,0.04,0.01],[0.06,0.1,0.04],t);} | |
| if(e<15){const t=e/15;return lerp3([0.06,0.1,0.04],[0.04,0.12,0.04],t);} | |
| return[0.04,0.12,0.04]; | |
| } | |
| function lerp3(a,b,t){return[a[0]+(b[0]-a[0])*t,a[1]+(b[1]-a[1])*t,a[2]+(b[2]-a[2])*t];} | |
| // ββ 16. PARTICLE SYSTEMS βββββββββββββββββββββββββββββββββββββββββ | |
| function updateRain(dt){ | |
| const wdR=SS.windDirection*Math.PI/180; | |
| const wdx=Math.sin(wdR)*SS.windSpeed*0.3,wdz=Math.cos(wdR)*SS.windSpeed*0.3; | |
| const spd=4+SS.windSpeed*0.3; | |
| for(const r of RAIN){ | |
| r.y-=spd*dt;r.x+=wdx*dt;r.z+=wdz*dt; | |
| if(r.y<-0.2){r.y=8+Math.random()*2;r.x=(Math.random()-.5)*18+wdx*2;r.z=(Math.random()-.5)*18+wdz*2;} | |
| } | |
| } | |
| function updateWindStreaks(dt){ | |
| const wdR=SS.windDirection*Math.PI/180; | |
| const speed=SS.windSpeed*0.5; | |
| const dx=Math.sin(wdR)*speed,dz=Math.cos(wdR)*speed; | |
| for(const s of WIND_STREAKS){ | |
| s.t+=dt; | |
| s.x+=dx*dt;s.z+=dz*dt; | |
| if(s.x>10||s.x<-10||s.z>10||s.z<-10){s.x=(Math.random()-.5)*20;s.z=(Math.random()-.5)*20;s.t=Math.random()*10;} | |
| s.alpha=SS.windSpeed>3?Math.min(0.12,SS.windSpeed*0.005):0; | |
| } | |
| } | |
| function updateEvap(dt){ | |
| const hot=SS.temperature>38&&SS.soilMoisture<50; | |
| for(const e of EVAP){ | |
| e.y+=e.vy*dt*(hot?1:0.3);e.life-=dt*0.2; | |
| if(e.life<=0){e.y=0;e.life=0.5+Math.random()*0.5;e.x=e.px+(Math.random()-.5)*2;e.z=e.pz+(Math.random()-.5)*2;} | |
| } | |
| } | |
| // Spray/water particle per-plant | |
| function spawnSprayParticles(plant){ | |
| plant.sprayParticles=[]; | |
| for(let i=0;i<12;i++){ | |
| const a=Math.random()*Math.PI*2,el=Math.random()*Math.PI/2; | |
| plant.sprayParticles.push({ | |
| x:plant.x,y:plant.growth*0.8,z:plant.z, | |
| vx:Math.cos(a)*Math.cos(el)*2,vy:Math.sin(el)*2+1,vz:Math.sin(a)*Math.cos(el)*2, | |
| life:1.0, | |
| }); | |
| } | |
| } | |
| function spawnWaterParticles(plant){ | |
| plant.waterParticles=[]; | |
| for(let i=0;i<8;i++){ | |
| const a=Math.random()*Math.PI*2,r=Math.random()*0.5; | |
| plant.waterParticles.push({ | |
| x:plant.x+Math.cos(a)*r,y:3+Math.random(),z:plant.z+Math.sin(a)*r, | |
| vx:(Math.random()-.5)*0.5,vy:-2-Math.random()*2,vz:(Math.random()-.5)*0.5, | |
| life:1.0,bounced:false, | |
| }); | |
| } | |
| } | |
| function updatePlantParticles(dt){ | |
| for(const p of SS.plants){ | |
| for(const sp of p.sprayParticles){sp.x+=sp.vx*dt;sp.y+=sp.vy*dt;sp.z+=sp.vz*dt;sp.vy-=4*dt;sp.life-=dt;} | |
| p.sprayParticles=p.sprayParticles.filter(s=>s.life>0); | |
| for(const wp of p.waterParticles){ | |
| wp.x+=wp.vx*dt;wp.y+=wp.vy*dt;wp.z+=wp.vz*dt; | |
| if(!wp.bounced&&wp.y<0.05){wp.vy*=-0.2;wp.vx*=0.3;wp.vz*=0.3;wp.bounced=true;} | |
| wp.life-=dt; | |
| } | |
| p.waterParticles=p.waterParticles.filter(w=>w.life>0); | |
| } | |
| } | |
| // ββ 17. HUD UPDATE ββββββββββββββββββββββββββββββββββββββββββββββ | |
| let frameCount=0,fpsTime=0,fpsVal=0; | |
| function updateFPS(dt){frameCount++;fpsTime+=dt;if(fpsTime>=0.5){fpsVal=Math.round(frameCount/fpsTime);frameCount=0;fpsTime=0;}} | |
| const statusColors={healthy:'#4e9',stressed:'#da6',diseased:'#f55',treated:'#5ae',poor:'#a66'}; | |
| function updateHUD(){ | |
| const S2=SS; | |
| // Sim time | |
| const h=Math.floor(S2.simHour),m=Math.floor((S2.simHour%1)*60); | |
| const wk=Math.ceil(S2.simDay/7); | |
| document.getElementById('hud-simtime').textContent= | |
| `Day ${S2.simDay} Β· Week ${wk} Β· ${S2.simSeason} Β· ${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}`; | |
| // Weather badge | |
| document.getElementById('hud-weather-badge').textContent=`π ${S2.locationName} Β· ${S2.weatherCondition}`; | |
| // Stats | |
| document.getElementById('s-fps').textContent=fpsVal; | |
| const tempEl=document.getElementById('s-temp'); | |
| tempEl.textContent=`${S2.temperature.toFixed(1)}Β°C`; | |
| const tStat=document.getElementById('s-temp-stat'); | |
| tStat.className='stat'+(S2.temperature>42?' crit':S2.temperature>35?' warn':''); | |
| document.getElementById('s-wind').textContent=`${S2.windSpeed.toFixed(1)}m/s @${Math.round(S2.windDirection)}Β° ${S2.windDirectionCardinal}`; | |
| const rainTxt=['OFF','LIGHT','MOD','HEAVY']; | |
| document.getElementById('s-rain').textContent=S2.isRaining?rainTxt[Math.ceil(S2.rainIntensity*2+0.1)]:'OFF'; | |
| document.getElementById('s-hum').textContent=`${Math.round(S2.humidity)}%`; | |
| document.getElementById('s-soil').textContent=`${Math.round(S2.soilMoisture)}%`; | |
| // Legend condition | |
| const conds={CLEAR:'β CLEAR',CLOUDY:'β CLOUDY',RAIN:'π§ RAIN',STORM:'β STORM',FOG:'π« FOG'}; | |
| document.getElementById('leg-cond').textContent=conds[S2.weatherCondition]||S2.weatherCondition; | |
| document.getElementById('hud-updated').textContent=`Updated: ${S2.lastWeatherUpdate||'--'}`; | |
| // Storm warning | |
| document.getElementById('storm-warning').style.display=S2.windSpeed>18?'block':'none'; | |
| // Plant list | |
| const pl=document.getElementById('plant-list'); | |
| pl.innerHTML=SS.plants.map(p=>` | |
| <div class="plant-row"> | |
| <div class="p-dot" style="background:${statusColors[p.status]||'#666'}"></div> | |
| <span class="p-name">P${p.id+1}</span> | |
| <span class="p-bars">H:${Math.round(p.health)}% W:${Math.round(p.water)}%</span> | |
| <span class="p-badge badge-${p.status==='healthy'?'ok':p.status==='stressed'?'stress':p.status==='diseased'?'disease':p.status==='treated'?'treated':'stress'}">${p.status==='treated'?`D${Math.floor(p.treatProgress*4)+1}/4`:p.status.toUpperCase().slice(0,4)}</span> | |
| </div>`).join(''); | |
| // Environment | |
| const sunPhase=S2.sunElevation>15?'DAY':S2.sunElevation>0?'SUNRISE':S2.sunElevation>-5?'GOLDEN':'NIGHT'; | |
| document.getElementById('e-sun').textContent=`${S2.sunElevation.toFixed(1)}Β° ${sunPhase}`; | |
| document.getElementById('e-cloud').textContent=`${Math.round(S2.cloudCover*100)}%`; | |
| document.getElementById('e-photo').textContent=`${Math.round(S2.photosynthesisRate*100)}%`; | |
| document.getElementById('e-evap').textContent=`${S2.evaporationRate.toFixed(2)}/hr`; | |
| // Field metrics | |
| document.getElementById('f-moist').textContent=`${Math.round(S2.soilMoisture)}%`; | |
| document.getElementById('f-temp').textContent=`${S2.temperature.toFixed(1)}Β°C`; | |
| const npk=`${Math.round(40+S2.avgHealth*.4)}-${Math.round(20+S2.avgHealth*.2)}-${Math.round(30+S2.avgHealth*.3)}`; | |
| document.getElementById('f-npk').textContent=npk; | |
| // Products | |
| const prodEl=document.getElementById('product-list'); | |
| if(SS.appliedProducts.length>0){ | |
| prodEl.innerHTML=SS.appliedProducts.map(p=>`<div class="product-row"><span>${p.name}</span><span>Day ${p.day}</span></div>`).join(''); | |
| } | |
| // Mode indicator | |
| const mi=document.getElementById('mode-indicator'); | |
| if(SS.waterModeActive){mi.style.display='block';mi.textContent='π§ WATER MODE β click a plant to irrigate';} | |
| else if(SS.sprayModeActive){mi.style.display='block';mi.textContent='πΏ SPRAY MODE β click a plant to treat';} | |
| else mi.style.display='none'; | |
| // Button states | |
| ['tomato','wheat','pepper','cucumber'].forEach(t=>{ | |
| document.getElementById('btn-'+t).className='btn btn-plant'+(SS.plantType===t?' active':''); | |
| }); | |
| document.getElementById('btn-water').className='btn btn-action'+(SS.waterModeActive?' water-active':''); | |
| document.getElementById('btn-spray').className='btn btn-action'+(SS.sprayModeActive?' spray-active':''); | |
| [1,6,24,168].forEach(s=>{ | |
| const el=document.getElementById('spd-'+s); | |
| if(el)el.className='btn btn-speed'+(SS.simSpeed===s?' active':''); | |
| }); | |
| } | |
| // ββ 18. PLANT TOOLTIP βββββββββββββββββββββββββββββββββββββββββββ | |
| let tooltipTarget=null,tooltipTimer=0; | |
| function showTooltip(plant,screenX,screenY){ | |
| const el=document.getElementById('plant-tooltip'); | |
| document.getElementById('tt-title').textContent=`Plant ${plant.id+1} (${SS.plantType})`; | |
| document.getElementById('tt-age').textContent=`${Math.floor(plant.age)} days`; | |
| document.getElementById('tt-health').textContent=`${Math.round(plant.health)}%`; | |
| document.getElementById('tt-water').textContent=`${Math.round(plant.water)}%`; | |
| document.getElementById('tt-growth').textContent=`${Math.round(plant.growth*100)}%`; | |
| document.getElementById('tt-fruits').textContent=plant.fruits; | |
| document.getElementById('tt-status').textContent=plant.status; | |
| const stressors=[]; | |
| if(SS.soilMoisture<20)stressors.push('Low moisture'); | |
| if(plant.water<25)stressors.push('Thirsty'); | |
| if(SS.temperature>42)stressors.push('Heat stress'); | |
| if(SS.temperature<5)stressors.push('Cold stress'); | |
| if(plant.diseased)stressors.push('Diseased'); | |
| document.getElementById('tt-stress').textContent=stressors.join(', ')||'None'; | |
| const daysLeft=Math.max(0,Math.round((90-plant.age)*(2-plant.health/100))); | |
| document.getElementById('tt-harvest').textContent=`~Day ${SS.simDay+daysLeft}`; | |
| const mw=mount.clientWidth,mh=mount.clientHeight; | |
| el.style.left=Math.min(screenX+10,mw-220)+'px'; | |
| el.style.top=Math.min(screenY-10,mh-300)+'px'; | |
| el.style.display='block'; | |
| tooltipTimer=2; | |
| } | |
| function hideTooltip(){document.getElementById('plant-tooltip').style.display='none';} | |
| // ββ 19. CLICK / INTERACTION ββββββββββββββββββββββββββββββββββββββ | |
| canvas.addEventListener('click',e=>{ | |
| const rect=canvas.getBoundingClientRect(); | |
| const mx=(e.clientX-rect.left)/rect.width*2-1; | |
| const my=-((e.clientY-rect.top)/rect.height*2-1); | |
| const eye=camEye(); | |
| const aspect=canvas.width/canvas.height; | |
| const proj=m4persp(0.7854,aspect,0.1,80); | |
| const view=m4look(eye[0],eye[1],eye[2],cam.tx,cam.ty,cam.tz); | |
| const vp=m4mul(proj,view); | |
| let best=null,bestDist=0.2; | |
| for(const p of SS.plants){ | |
| // Project plant world pos to NDC | |
| const wx=p.x,wy=p.y+p.growth*0.6,wz=p.z; | |
| const clip=[ | |
| vp[0]*wx+vp[4]*wy+vp[8]*wz+vp[12], | |
| vp[1]*wx+vp[5]*wy+vp[9]*wz+vp[13], | |
| vp[2]*wx+vp[6]*wy+vp[10]*wz+vp[14], | |
| vp[3]*wx+vp[7]*wy+vp[11]*wz+vp[15], | |
| ]; | |
| if(clip[3]<0.1)continue; | |
| const ndcX=clip[0]/clip[3],ndcY=clip[1]/clip[3]; | |
| const d=Math.sqrt((ndcX-mx)**2+(ndcY-my)**2); | |
| if(d<bestDist){bestDist=d;best=p;} | |
| } | |
| if(best){ | |
| if(SS.waterModeActive){ | |
| best.water=Math.min(100,best.water+25); | |
| best.health=Math.min(100,best.health+4); | |
| SS.soilMoisture=Math.min(100,SS.soilMoisture+15); | |
| spawnWaterParticles(best); | |
| }else if(SS.sprayModeActive){ | |
| if(!best.treated){ // don't re-apply if already in treatment | |
| best.treated=true; | |
| best.treatStartDay=SS.simDay+(SS.simHour/24); | |
| best.treatProgress=0; | |
| spawnSprayParticles(best); | |
| SS.appliedProducts.push({name:`Spray P${best.id+1}`,day:SS.simDay}); | |
| } | |
| }else{ | |
| showTooltip(best,e.clientX,e.clientY); | |
| } | |
| }else{ | |
| hideTooltip(); | |
| } | |
| }); | |
| // ββ 20. RENDER HELPERS βββββββββββββββββββββββββββββββββββββββββββ | |
| function setLighting(lightDir,lightCol,ambient,eyePos,fogCol,fogNear,fogFar){ | |
| gl.useProgram(progSolid); | |
| gl.uniform3fv(S.uLightDir,lightDir);gl.uniform3fv(S.uLightCol,lightCol); | |
| gl.uniform1f(S.uAmbient,ambient);gl.uniform3fv(S.uEyePos,eyePos); | |
| gl.uniform3fv(S.uFogCol,fogCol);gl.uniform1f(S.uFogNear,fogNear);gl.uniform1f(S.uFogFar,fogFar); | |
| } | |
| function drawMeshAt(buf,vp,px,py,pz,sx,sy,sz,rx,ry,rz,tint,alpha){ | |
| let model=m4tr(px,py,pz); | |
| if(ry!==0)model=m4mul(model,m4ry(ry)); | |
| if(rx!==0)model=m4mul(model,m4rx(rx)); | |
| if(rz!==0)model=m4mul(model,m4rz(rz)); | |
| if(sx!==1||sy!==1||sz!==1)model=m4mul(model,m4sc(sx,sy,sz)); | |
| const mvp=m4mul(vp,model); | |
| drawMesh(buf,mvp,model,tint,alpha); | |
| } | |
| // ββ 21. PLANT RENDERING ββββββββββββββββββββββββββββββββββββββββββ | |
| const healthTints={ | |
| healthy:[1,1,1],stressed:[1.1,0.75,0.3],diseased:[1.2,0.4,0.4], | |
| treated:[0.5,0.85,1.2],poor:[0.8,0.8,0.5] | |
| }; | |
| function renderPlants(vp,t){ | |
| const PM=PLANTS_MESH[SS.plantType]; | |
| if(!PM)return; | |
| for(const p of SS.plants){ | |
| const tint=healthTints[p.status]||[1,1,1]; | |
| const g=Math.max(0.05,p.growth); | |
| const freq=PM.swayFreq||1; | |
| const swayZ=Math.sin(t*freq+p.id*1.3)*SS.windSpeed*0.012; | |
| const swayX=Math.cos(t*freq*0.7+p.id*0.9)*SS.windSpeed*0.008; | |
| const baseModel=m4mul(m4tr(p.x,p.y,p.z),m4mul(m4sc(g,g,g),m4rz(swayZ))); | |
| for(const part of PM.parts){ | |
| const partModel=m4mul(baseModel,m4tr(part.tx||0,part.ty||0,part.tz||0)); | |
| const scalePart=m4mul(partModel,(part.sx||part.sy||part.sz)?m4sc(part.sx||1,part.sy||1,part.sz||1):m4id()); | |
| drawMesh(part.buf,m4mul(vp,scalePart),scalePart,tint,1); | |
| } | |
| // Fruits | |
| const maxF=PM.maxFruits||0; | |
| const ripeWeek=PM.fruitWeek||999; | |
| if(maxF>0&&PM.fruitPos&&p.fruits>0){ | |
| const numFruits=Math.min(p.fruits,PM.fruitPos.length); | |
| for(let fi=0;fi<numFruits;fi++){ | |
| const fp=PM.fruitPos[fi]; | |
| const ripe=p.age>ripeWeek*7; | |
| const fBuf=ripe?(PM.fruitRipe||PM.fruitUnripe):PM.fruitUnripe; | |
| if(!fBuf)continue; | |
| let fm=m4mul(baseModel,m4tr(fp[0],fp[1],fp[2])); | |
| if(PM.fruitRot==='x')fm=m4mul(fm,m4rx(Math.PI/2)); | |
| else if(PM.fruitRot===true)fm=m4mul(fm,m4rx(-0.5)); | |
| drawMesh(fBuf,m4mul(vp,fm),fm,tint,1); | |
| } | |
| } | |
| } | |
| } | |
| // ββ 22. GROUND RENDERING ββββββββββββββββββββββββββββββββββββββββ | |
| function renderGround(vp){ | |
| // Topsoil color based on moisture | |
| const wet=[0.23,0.12,0.04],dry=[0.77,0.67,0.52]; | |
| const t=SS.soilMoisture/100; | |
| const tc=[wet[0]+(dry[0]-wet[0])*(1-t),wet[1]+(dry[1]-wet[1])*(1-t),wet[2]+(dry[2]-wet[2])*(1-t)]; | |
| // Soil layer 1 (topsoil) - tint by moisture | |
| drawMeshAt(GROUND_LAYER,vp,0,-0.04,0,1,1,1,0,0,0,tc,1); | |
| drawMeshAt(GROUND_SUB,vp,0,-0.23,0,1,1,1,0,0,0,[0.18,0.09,0.04],1); | |
| drawMeshAt(GROUND_BED,vp,0,-0.505,0,1,1,1,0,0,0,[0.06,0.03,0.08],1); | |
| // Puddles | |
| if(SS.soilMoisture>82){ | |
| const puddles=[[-2,0,-2],[1,0,2],[-1.5,0,1.5]]; | |
| const pa=Math.min(0.6,(SS.soilMoisture-82)/18*0.6); | |
| gl.blendFunc(gl.SRC_ALPHA,gl.ONE); | |
| for(const [px,py,pz] of puddles){ | |
| drawMeshAt(PUDDLE_MESH,vp,px,0.01,pz,1,1,1,0,0,0,[0.15,0.35,0.6],pa); | |
| } | |
| gl.blendFunc(gl.SRC_ALPHA,gl.ONE_MINUS_SRC_ALPHA); | |
| } | |
| } | |
| // ββ 23. DYNAMIC LINES RENDERING ββββββββββββββββββββββββββββββββββ | |
| function buildLineData(vp,t){ | |
| const LD=DYN_LINE.data; | |
| let off=0; | |
| // Growth paths β green spiral per plant | |
| for(const p of SS.plants){ | |
| if(p.growth<0.05)continue; | |
| const h=p.growth;const col=statusColors[p.status]||'#4e9'; | |
| const rc=p.status==='healthy'?[0.27,0.87,0.5]:p.status==='stressed'?[0.87,0.67,0.2]:p.status==='diseased'?[0.87,0.2,0.2]:[0.3,0.6,1]; | |
| const alpha=0.3*SS.photosynthesisRate*(0.5+0.5*Math.sin(t*1.5+p.id)); | |
| for(let seg=0;seg<12&&off+14<LD.length;seg++){ | |
| const y0=(seg/12)*h,y1=((seg+1)/12)*h; | |
| const a0=seg*0.5+t*0.3,a1=(seg+1)*0.5+t*0.3; | |
| const r=0.15+seg*0.02; | |
| off=pushLine(LD,off,p.x+Math.cos(a0)*r,y0,p.z+Math.sin(a0)*r,p.x+Math.cos(a1)*r,y1,p.z+Math.sin(a1)*r,rc[0],rc[1],rc[2],alpha); | |
| } | |
| } | |
| // Root lines | |
| for(const p of SS.plants){ | |
| const rootLen=Math.min(0.8,0.2+p.age*0.008+SS.soilMoisture*0.003); | |
| const moist=SS.soilMoisture>40; | |
| const rc=moist?[0.3,0.6,0.9]:[0.8,0.5,0.15]; | |
| for(let ri=0;ri<4&&off+14<LD.length;ri++){ | |
| const a=(ri/4)*Math.PI*2,dx=Math.cos(a)*0.4,dz=Math.sin(a)*0.4; | |
| off=pushLine(LD,off,p.x,0,p.z,p.x+dx,-rootLen,p.z+dz,rc[0],rc[1],rc[2],0.4); | |
| } | |
| } | |
| // Water flow rings | |
| for(const p of SS.plants){ | |
| if(SS.soilMoisture<30)continue; | |
| const alpha=SS.soilMoisture/400;const rot=t*0.3; | |
| const rc=[0.23,0.55,0.85]; | |
| for(let ri=0;ri<8&&off+14<LD.length;ri++){ | |
| const a0=(ri/8)*Math.PI*2+rot,a1=((ri+1)/8)*Math.PI*2+rot; | |
| off=pushLine(LD,off,p.x+Math.cos(a0)*0.8,0.01,p.z+Math.sin(a0)*0.8,p.x+Math.cos(a1)*0.8,0.01,p.z+Math.sin(a1)*0.8,rc[0],rc[1],rc[2],alpha); | |
| } | |
| } | |
| // Disease spread lines | |
| const diseased=SS.plants.filter(p=>p.diseased); | |
| for(let i=0;i<diseased.length;i++)for(let j=i+1;j<diseased.length&&off+14<LD.length;j++){ | |
| const a=diseased[i],b2=diseased[j]; | |
| const d=Math.sqrt((a.x-b2.x)**2+(a.z-b2.z)**2); | |
| if(d<4){ | |
| const pulse=0.25*(0.5+0.5*Math.sin(t*3)); | |
| off=pushLine(LD,off,a.x,0.1,a.z,b2.x,0.1,b2.z,0.87,0.15,0.15,pulse); | |
| } | |
| } | |
| // Spray coverage | |
| const treated=SS.plants.filter(p=>p.treated); | |
| for(let i=0;i<treated.length;i++)for(let j=i+1;j<treated.length&&off+14<LD.length;j++){ | |
| const a=treated[i],b2=treated[j]; | |
| const d=Math.sqrt((a.x-b2.x)**2+(a.z-b2.z)**2); | |
| if(d<6){ | |
| const pulse=0.28*(0.5+0.5*Math.sin(t*4+i)); | |
| off=pushLine(LD,off,a.x,0.15,a.z,b2.x,0.15,b2.z,0.87,0.63,0.16,pulse); | |
| } | |
| } | |
| // Wind direction arrow | |
| if(SS.windSpeed>1&&off+42<LD.length){ | |
| const wdR=SS.windDirection*Math.PI/180; | |
| const len=SS.windSpeed*0.15,wx=Math.sin(wdR)*len,wz=Math.cos(wdR)*len; | |
| off=pushLine(LD,off,0,0.2,0,wx,0.2,wz,0.9,0.9,0.9,0.5); | |
| const ah=0.15,aR1=wdR+2.7,aR2=wdR-2.7; | |
| off=pushLine(LD,off,wx,0.2,wz,wx+Math.sin(aR1)*ah,0.2,wz+Math.cos(aR1)*ah,0.9,0.9,0.9,0.5); | |
| off=pushLine(LD,off,wx,0.2,wz,wx+Math.sin(aR2)*ah,0.2,wz+Math.cos(aR2)*ah,0.9,0.9,0.9,0.5); | |
| } | |
| // Append rain + wind streaks as line pairs | |
| off=appendRainAndWind(LD,off); | |
| const lineVerts=off/7; | |
| updateDynBuf(DYN_LINE,LD,lineVerts); | |
| drawLines(DYN_LINE,lineVerts,vp); | |
| return lineVerts; | |
| } | |
| // ββ 24. PARTICLE RENDERING βββββββββββββββββββββββββββββββββββββββ | |
| // Rain lines and wind streaks are appended to DYN_LINE (drawn as GL_LINES) | |
| // Spray/water/evap/photo go to DYN_PART (drawn as GL_POINTS) | |
| function appendRainAndWind(LD, startOff){ | |
| let off=startOff; | |
| if(SS.isRaining){ | |
| const wdR=SS.windDirection*Math.PI/180; | |
| const ldx=Math.sin(wdR)*0.3,ldz=Math.cos(wdR)*0.3; | |
| for(const r of RAIN){ | |
| if(off+14>=LD.length)break; | |
| const a=SS.rainIntensity*0.6; | |
| off=pushLine(LD,off,r.x,r.y,r.z,r.x+ldx,r.y+0.3,r.z+ldz,0.7,0.83,1,a); | |
| } | |
| } | |
| for(const s of WIND_STREAKS){ | |
| if(s.alpha<=0||off+14>=LD.length)continue; | |
| const wdR=SS.windDirection*Math.PI/180; | |
| const len=0.8,dx=Math.sin(wdR)*len,dz=Math.cos(wdR)*len; | |
| off=pushLine(LD,off,s.x,s.y,s.z,s.x-dx,s.y,s.z-dz,1,1,1,s.alpha); | |
| } | |
| return off; | |
| } | |
| function renderParticles(vp,t,lineVertCount){ | |
| // DYN_PART gets only point-mode particles | |
| const PD=DYN_PART.data; | |
| let off=0; | |
| // Spray + water | |
| for(const p of SS.plants){ | |
| for(const sp of p.sprayParticles){ | |
| if(off+7>=PD.length)break; | |
| off=pushVert(PD,off,sp.x,sp.y,sp.z,0.31,0.86,0.7,sp.life); | |
| } | |
| for(const wp of p.waterParticles){ | |
| if(off+7>=PD.length)break; | |
| off=pushVert(PD,off,wp.x,wp.y,wp.z,0.39,0.63,1,wp.life); | |
| } | |
| } | |
| // Evaporation | |
| const hot=SS.temperature>38&&SS.soilMoisture<50; | |
| for(const e of EVAP){ | |
| if(off+7>=PD.length||!hot)break; | |
| off=pushVert(PD,off,e.x,e.y,e.z,0.78,0.7,0.55,e.life*0.3); | |
| } | |
| // Photo glow | |
| for(const p of SS.plants){ | |
| if(p.photoGlow<0.05||off+7>=PD.length)continue; | |
| off=pushVert(PD,off,p.x,p.growth*0.7,p.z,0.3,0.9,0.4,p.photoGlow*0.2); | |
| } | |
| const ptCount=off/7; | |
| if(ptCount>0){ | |
| updateDynBuf(DYN_PART,PD,ptCount); | |
| gl.blendFunc(gl.SRC_ALPHA,gl.ONE); | |
| drawPoints(DYN_PART,ptCount,vp); | |
| gl.blendFunc(gl.SRC_ALPHA,gl.ONE_MINUS_SRC_ALPHA); | |
| } | |
| } | |
| // ββ 25. SKY OBJECTS ββββββββββββββββββββββββββββββββββββββββββββββ | |
| function renderSkyObjects(vp,sunPos,sunEl){ | |
| gl.disable(gl.CULL_FACE); | |
| // Sun | |
| if(sunEl>-5){ | |
| const brightness=Math.max(0.2,Math.min(1,(sunEl+5)/20)); | |
| drawMeshAt(SUN_MESH,vp,sunPos[0],sunPos[1],sunPos[2],1,1,1,0,0,0,[brightness,brightness*0.9,brightness*0.4],1); | |
| } | |
| // Moon (opposite side) | |
| const moonEl=-sunEl,moonAz=SS.sunAzimuth+180; | |
| const mPos=sunWorldPos(moonEl,moonAz); | |
| if(moonEl>-10){ | |
| drawMeshAt(MOON_MESH,vp,mPos[0],mPos[1],mPos[2],1,1,1,0,0,0,[0.85,0.83,0.78],1); | |
| } | |
| // Stars | |
| if(sunEl<5){ | |
| const starAlpha=Math.max(0,Math.min(1,(-sunEl)/10)); | |
| for(const s of STARS){ | |
| drawMeshAt(STAR_MESH,vp,s[0],s[1],s[2],1,1,1,0,0,0,[0.92,0.9,0.85],starAlpha); | |
| } | |
| } | |
| // Clouds | |
| const cScale=Math.max(1,SS.cloudCover*2); | |
| for(const c of CLOUDS){ | |
| const cloudAlpha=0.65*SS.cloudCover; | |
| drawMeshAt(CLOUD_MESH,vp,c.x,c.y,c.z,c.scale*cScale,c.scale*0.25,c.scale*cScale,0,0,0,[0.88,0.88,0.9],cloudAlpha); | |
| } | |
| gl.enable(gl.CULL_FACE); | |
| } | |
| // ββ 26. MAIN RENDER LOOP βββββββββββββββββββββββββββββββββββββββββ | |
| let lastT=0,simTime=0; | |
| function render(now){ | |
| now*=0.001; | |
| const dt=Math.min((now-lastT),0.1);lastT=now; | |
| simTime+=dt; | |
| updateFPS(dt); | |
| lerpCam(); | |
| // Solar position | |
| const doy=Math.floor(SS.simDay%365)+1; | |
| const sun=calcSun(SS.latitude,SS.longitude,doy,SS.simHour); | |
| SS.sunElevation=sun.el;SS.sunAzimuth=sun.az; | |
| const sunPos=sunWorldPos(sun.el,sun.az); | |
| const sunDir=[sunPos[0],sunPos[1],sunPos[2]]; | |
| const sdLen=Math.sqrt(sunDir[0]**2+sunDir[1]**2+sunDir[2]**2)||1; | |
| const snorm=[sunDir[0]/sdLen,sunDir[1]/sdLen,sunDir[2]/sdLen]; | |
| // Lighting params | |
| const isDay=sun.el>0; | |
| const isGolden=sun.el>0&&sun.el<15; | |
| let lightCol=[1,1,1],ambient=isDay?0.18:0.06; | |
| if(isGolden){lightCol=[1,0.8,0.45];ambient=0.25;} | |
| if(!isDay){lightCol=[0.2,0.25,0.4];ambient=0.06;} | |
| const lightDir=isDay?snorm:[0,1,0]; | |
| // Sky | |
| const skyCol=skyColor(sun.el); | |
| gl.clearColor(skyCol[0],skyCol[1],skyCol[2],1); | |
| gl.clear(gl.COLOR_BUFFER_BIT|gl.DEPTH_BUFFER_BIT); | |
| // Matrices | |
| const eye=camEye(); | |
| const aspect=canvas.width/canvas.height; | |
| const proj=m4persp(0.7854,aspect,0.1,80); | |
| const view=m4look(eye[0],eye[1],eye[2],cam.tx,cam.ty,cam.tz); | |
| const vp=m4mul(proj,view); | |
| const fogNear=10,fogFar=35; | |
| setLighting(lightDir,lightCol,ambient,eye,skyCol,fogNear,fogFar); | |
| // Update clouds position | |
| const wdR=SS.windDirection*Math.PI/180; | |
| for(const c of CLOUDS){ | |
| c.x+=Math.sin(wdR)*c.speed*dt*0.1;c.z+=Math.cos(wdR)*c.speed*dt*0.1; | |
| if(c.x>14)c.x=-14;if(c.x<-14)c.x=14;if(c.z>14)c.z=-14;if(c.z<-14)c.z=14; | |
| } | |
| // Render | |
| renderSkyObjects(vp,sunPos,sun.el); | |
| renderGround(vp); | |
| renderPlants(vp,simTime); | |
| // Particles update | |
| updateRain(dt);updateWindStreaks(dt);updateEvap(dt);updatePlantParticles(dt); | |
| // renderLines fills DYN_LINE, then we append rain/wind to same buffer | |
| const lineVerts=buildLineData(vp,simTime); | |
| renderParticles(vp,simTime,lineVerts); | |
| // Physics | |
| updatePhysics(dt,SS.simHour); | |
| // Tooltip timer | |
| if(tooltipTimer>0){tooltipTimer-=dt;if(tooltipTimer<=0)hideTooltip();} | |
| // HUD (every ~10 frames) | |
| if(Math.floor(simTime*30)%10===0)updateHUD(); | |
| requestAnimationFrame(render); | |
| } | |
| // ββ 27. GLOBAL API βββββββββββββββββββββββββββββββββββββββββββββββ | |
| window.simSetPlant=function(type){ | |
| SS.plantType=type; | |
| // Reset fruits when switching | |
| for(const p of SS.plants){p.fruits=0;} | |
| }; | |
| window.simToggleWater=function(){ | |
| SS.waterModeActive=!SS.waterModeActive;if(SS.waterModeActive)SS.sprayModeActive=false; | |
| }; | |
| window.simToggleSpray=function(){ | |
| SS.sprayModeActive=!SS.sprayModeActive;if(SS.sprayModeActive)SS.waterModeActive=false; | |
| }; | |
| window.simAddDisease=function(){ | |
| const healthy=SS.plants.filter(p=>!p.diseased); | |
| if(healthy.length>0){const p=healthy[Math.floor(Math.random()*healthy.length)];p.diseased=true;p.status='diseased';} | |
| }; | |
| window.simAdvanceDay=function(){ | |
| SS.simHour+=24;if(SS.simHour>=24){SS.simHour%=24;SS.simDay++;} | |
| for(const p of SS.plants){p.age+=1;} | |
| }; | |
| window.simAdvanceWeek=function(){ | |
| SS.simDay+=7;SS.simHour=SS.simHour%24; | |
| for(const p of SS.plants){p.age+=7;p.growth=Math.min(1,p.growth+0.05);} | |
| }; | |
| window.simSetSpeed=function(n){SS.simSpeed=n;}; | |
| // ββ 28. INIT βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| initLocation(); | |
| requestAnimationFrame(render); | |
| // Cleanup | |
| const _intv=setInterval(fetchWeather,600000); | |
| const obs=new MutationObserver(()=>{ | |
| if(!document.contains(mount)){clearInterval(_intv);obs.disconnect();} | |
| }); | |
| obs.observe(document.body,{childList:true,subtree:true}); | |
| })(); | |
| </script> | |
| </body> | |
| </html> | |