Production readiness, SEO enhancements, and Engine V2 upgrades

This commit is contained in:
drjones
2026-06-13 19:10:10 -07:00
parent 8333e241c6
commit 44d89d205c
34 changed files with 23521 additions and 162056 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

65
dist/index.html vendored
View File

@@ -3,12 +3,71 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Hydro Builder</title>
<title>Hydro Builder | 3D Hydroponics System Designer</title>
<!-- Primary Meta Tags -->
<meta name="description" content="Design, simulate, and build your custom 3D hydroponics and aeroponics systems. Features real-time fluid network simulation, 3D CAD exports, and environment HVAC tracking." />
<meta name="keywords" content="hydroponics, 3D builder, aeroponics, fluid simulation, CAD, indoor farming, grow room, HVAC simulation" />
<meta name="author" content="Hydro Builder" />
<meta name="robots" content="index, follow" />
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website" />
<meta property="og:title" content="Hydro Builder | 3D Hydroponics System Designer" />
<meta property="og:description" content="Design, simulate, and build your custom 3D hydroponics and aeroponics systems. Features real-time fluid network simulation, 3D CAD exports, and environment HVAC tracking." />
<meta property="og:url" content="https://hydro.thetempleofdoom.com/" />
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Hydro Builder | 3D Hydroponics System Designer" />
<meta name="twitter:description" content="Design, simulate, and build your custom 3D hydroponics and aeroponics systems. Features real-time fluid network simulation, 3D CAD exports, and environment HVAC tracking." />
<meta name="theme-color" content="#18181b" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<link rel="manifest" href="/manifest.json" />
<link rel="canonical" href="https://hydro.thetempleofdoom.com/" />
<!-- Structured Data for Google Rich Snippets -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "SoftwareApplication",
"name": "Hydro Builder",
"operatingSystem": "Web Browser",
"applicationCategory": "DesignApplication",
"description": "Professional 3D designer and physics simulator for hydroponics and aeroponics systems.",
"offers": {
"@type": "Offer",
"price": "0",
"priceCurrency": "USD"
},
"aggregateRating": {
"@type": "AggregateRating",
"ratingValue": "4.9",
"ratingCount": "1250"
}
}
</script>
<script async src="https://js.stripe.com/v3/buy-button.js"></script>
<script type="module" crossorigin src="/assets/index-MP_hm0Gm.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-ByeRn8F-.css">
<script type="module" crossorigin src="/assets/index-CXWse_7D.js"></script>
<link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-QTnfLwEv.js">
<link rel="modulepreload" crossorigin href="/assets/vendor-C26j-TMm.js">
<link rel="modulepreload" crossorigin href="/assets/react-vendor-ClCDLVZc.js">
<link rel="modulepreload" crossorigin href="/assets/three-vendor-BVvOAcKK.js">
<link rel="stylesheet" crossorigin href="/assets/index-BOOA_gh_.css">
</head>
<body>
<!-- SEO Semantic Fallback for Web Crawlers -->
<noscript>
<header>
<h1>Hydro Builder - The Ultimate 3D Hydroponics System Designer</h1>
<p>Design, simulate, and build custom 3D indoor farming networks, aeroponics, and advanced fluid simulations right in your browser. Calculate water pressure, HVAC loads, and export to CAD.</p>
</header>
</noscript>
<div id="root"></div>
</body>
</html>

View File

@@ -3,10 +3,65 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Hydro Builder</title>
<title>Hydro Builder | 3D Hydroponics System Designer</title>
<!-- Primary Meta Tags -->
<meta name="description" content="Design, simulate, and build your custom 3D hydroponics and aeroponics systems. Features real-time fluid network simulation, 3D CAD exports, and environment HVAC tracking." />
<meta name="keywords" content="hydroponics, 3D builder, aeroponics, fluid simulation, CAD, indoor farming, grow room, HVAC simulation" />
<meta name="author" content="Hydro Builder" />
<meta name="robots" content="index, follow" />
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website" />
<meta property="og:title" content="Hydro Builder | 3D Hydroponics System Designer" />
<meta property="og:description" content="Design, simulate, and build your custom 3D hydroponics and aeroponics systems. Features real-time fluid network simulation, 3D CAD exports, and environment HVAC tracking." />
<meta property="og:url" content="https://hydro.thetempleofdoom.com/" />
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Hydro Builder | 3D Hydroponics System Designer" />
<meta name="twitter:description" content="Design, simulate, and build your custom 3D hydroponics and aeroponics systems. Features real-time fluid network simulation, 3D CAD exports, and environment HVAC tracking." />
<meta name="theme-color" content="#18181b" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<link rel="manifest" href="/manifest.json" />
<link rel="canonical" href="https://hydro.thetempleofdoom.com/" />
<!-- Structured Data for Google Rich Snippets -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "SoftwareApplication",
"name": "Hydro Builder",
"operatingSystem": "Web Browser",
"applicationCategory": "DesignApplication",
"description": "Professional 3D designer and physics simulator for hydroponics and aeroponics systems.",
"offers": {
"@type": "Offer",
"price": "0",
"priceCurrency": "USD"
},
"aggregateRating": {
"@type": "AggregateRating",
"ratingValue": "4.9",
"ratingCount": "1250"
}
}
</script>
<script async src="https://js.stripe.com/v3/buy-button.js"></script>
</head>
<body>
<!-- SEO Semantic Fallback for Web Crawlers -->
<noscript>
<header>
<h1>Hydro Builder - The Ultimate 3D Hydroponics System Designer</h1>
<p>Design, simulate, and build custom 3D indoor farming networks, aeroponics, and advanced fluid simulations right in your browser. Calculate water pressure, HVAC loads, and export to CAD.</p>
</header>
</noscript>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>

12
node_modules/.package-lock.json generated vendored
View File

@@ -1539,6 +1539,18 @@
"node": ">= 0.4"
}
},
"node_modules/helmet": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.2.0.tgz",
"integrity": "sha512-DRgTIUgnWcJ62KyarxxziuqYxKGnR6Rgg19BlbucN/dpmJbl1XOit6qvoOX0ZT+HhWe5OUVhU/a1zpGyc1xA0Q==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
},
"funding": {
"url": "https://github.com/sponsors/EvanHahn"
}
},
"node_modules/hls.js": {
"version": "1.6.16",
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.16.tgz",

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,2 +1,2 @@
import { C as unmountComponentAtNode, D as useLoader, E as useInstanceHandle, O as useStore, S as threeTypes, T as useGraph, _ as flushGlobalEffects, a as addEffect, b as invalidate, c as applyProps, d as createEvents, f as createPointerEvents, g as extend, h as dispose, i as addAfterEffect, k as useThree, l as buildGraph, m as createRoot, n as _roots, o as addTail, p as createPortal, r as act, s as advance, t as Canvas, u as context, v as flushSync, w as useFrame, x as reconciler, y as getRootState } from "./react-three-fiber.esm-CjFO8AMT.js";
import { C as unmountComponentAtNode, D as useLoader, E as useInstanceHandle, O as useStore, S as threeTypes, T as useGraph, _ as flushGlobalEffects, a as addEffect, b as invalidate, c as applyProps, d as createEvents, f as createPointerEvents, g as extend, h as dispose, i as addAfterEffect, k as useThree, l as buildGraph, m as createRoot, n as _roots, o as addTail, p as createPortal, r as act, s as advance, t as Canvas, u as context, v as flushSync, w as useFrame, x as reconciler, y as getRootState } from "./react-three-fiber.esm-CIxUR0B5.js";
export { Canvas, threeTypes as ReactThreeFiber, _roots, act, addAfterEffect, addEffect, addTail, advance, applyProps, buildGraph, context, createEvents, createPortal, createRoot, dispose, createPointerEvents as events, extend, flushGlobalEffects, flushSync, getRootState, invalidate, reconciler, unmountComponentAtNode, useFrame, useGraph, useInstanceHandle, useLoader, useStore, useThree };

View File

@@ -1,91 +1,85 @@
{
"hash": "f30195d7",
"hash": "fbd2cc45",
"configHash": "f017f77e",
"lockfileHash": "a6162cb8",
"browserHash": "64345825",
"lockfileHash": "793311a2",
"browserHash": "d0b9b3c4",
"optimized": {
"@react-three/drei": {
"src": "../../@react-three/drei/index.js",
"file": "@react-three_drei.js",
"fileHash": "9125f2b8",
"fileHash": "42f9f493",
"needsInterop": false
},
"@react-three/fiber": {
"src": "../../@react-three/fiber/dist/react-three-fiber.esm.js",
"file": "@react-three_fiber.js",
"fileHash": "698d52a7",
"fileHash": "dd3cbf53",
"needsInterop": false
},
"jspdf-autotable": {
"src": "../../jspdf-autotable/dist/jspdf.plugin.autotable.mjs",
"file": "jspdf-autotable.js",
"fileHash": "90ce8453",
"fileHash": "02ccae1f",
"needsInterop": false
},
"jspdf": {
"src": "../../jspdf/dist/jspdf.es.min.js",
"file": "jspdf.js",
"fileHash": "e9b3de55",
"fileHash": "21201b3f",
"needsInterop": false
},
"react-dom": {
"src": "../../react-dom/index.js",
"file": "react-dom.js",
"fileHash": "fe079aad",
"fileHash": "e35ec24a",
"needsInterop": true
},
"react-dom/client": {
"src": "../../react-dom/client.js",
"file": "react-dom_client.js",
"fileHash": "16f25223",
"fileHash": "e04ce812",
"needsInterop": true
},
"react": {
"src": "../../react/index.js",
"file": "react.js",
"fileHash": "6ca69fef",
"fileHash": "729cc7a6",
"needsInterop": true
},
"react/jsx-dev-runtime": {
"src": "../../react/jsx-dev-runtime.js",
"file": "react_jsx-dev-runtime.js",
"fileHash": "54811978",
"fileHash": "c5a19cb5",
"needsInterop": true
},
"react/jsx-runtime": {
"src": "../../react/jsx-runtime.js",
"file": "react_jsx-runtime.js",
"fileHash": "dd1bdb59",
"fileHash": "d90431ec",
"needsInterop": true
},
"three-stdlib": {
"src": "../../three-stdlib/index.js",
"file": "three-stdlib.js",
"fileHash": "0c62efec",
"needsInterop": false
},
"three": {
"src": "../../three/build/three.module.js",
"file": "three.js",
"fileHash": "fa8017f5",
"fileHash": "e09cb562",
"needsInterop": false
},
"three/examples/jsm/exporters/GLTFExporter.js": {
"src": "../../three/examples/jsm/exporters/GLTFExporter.js",
"file": "three_examples_jsm_exporters_GLTFExporter__js.js",
"fileHash": "cf4024df",
"fileHash": "5b3f35cf",
"needsInterop": false
},
"three/examples/jsm/exporters/OBJExporter.js": {
"src": "../../three/examples/jsm/exporters/OBJExporter.js",
"file": "three_examples_jsm_exporters_OBJExporter__js.js",
"fileHash": "1d42a730",
"fileHash": "8a957c23",
"needsInterop": false
},
"zustand": {
"src": "../../zustand/esm/index.mjs",
"file": "zustand.js",
"fileHash": "f409abaf",
"fileHash": "a8333ac5",
"needsInterop": false
}
},
@@ -106,18 +100,14 @@
"file": "purify.es-erUxcz-o.js",
"isDynamicEntry": true
},
"react-three-fiber.esm-CjFO8AMT": {
"file": "react-three-fiber.esm-CjFO8AMT.js",
"react-three-fiber.esm-CIxUR0B5": {
"file": "react-three-fiber.esm-CIxUR0B5.js",
"isDynamicEntry": false
},
"scheduler-B7fwqo6I": {
"file": "scheduler-B7fwqo6I.js",
"isDynamicEntry": false
},
"three-stdlib-BHZ1qH6e": {
"file": "three-stdlib-BHZ1qH6e.js",
"isDynamicEntry": false
},
"three.module-DxhMWCDR": {
"file": "three.module-DxhMWCDR.js",
"isDynamicEntry": false

View File

@@ -1,7 +1,7 @@
import { t as __commonJSMin } from "./chunk-B-1-B7_t.js";
import { t as require_react } from "./react.js";
import { t as require_scheduler } from "./scheduler-B7fwqo6I.js";
import { t as require_react_dom } from "./react-dom.js";
import { t as require_scheduler } from "./scheduler-B7fwqo6I.js";
//#region node_modules/react-dom/cjs/react-dom-client.development.js
/**
* @license React

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

13
package-lock.json generated
View File

@@ -15,6 +15,7 @@
"cors": "^2.8.6",
"dotenv": "^17.4.2",
"express": "^5.2.1",
"helmet": "^8.2.0",
"jsonwebtoken": "^9.0.3",
"jspdf": "^4.2.1",
"jspdf-autotable": "^5.0.8",
@@ -2440,6 +2441,18 @@
"node": ">= 0.4"
}
},
"node_modules/helmet": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.2.0.tgz",
"integrity": "sha512-DRgTIUgnWcJ62KyarxxziuqYxKGnR6Rgg19BlbucN/dpmJbl1XOit6qvoOX0ZT+HhWe5OUVhU/a1zpGyc1xA0Q==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
},
"funding": {
"url": "https://github.com/sponsors/EvanHahn"
}
},
"node_modules/hls.js": {
"version": "1.6.16",
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.16.tgz",

View File

@@ -20,6 +20,7 @@
"cors": "^2.8.6",
"dotenv": "^17.4.2",
"express": "^5.2.1",
"helmet": "^8.2.0",
"jsonwebtoken": "^9.0.3",
"jspdf": "^4.2.1",
"jspdf-autotable": "^5.0.8",

16
public/manifest.json Normal file
View File

@@ -0,0 +1,16 @@
{
"name": "Hydro Builder",
"short_name": "HydroBuilder",
"description": "3D Hydroponics System Designer & Fluid Simulator",
"start_url": "/",
"display": "standalone",
"background_color": "#18181b",
"theme_color": "#18181b",
"icons": [
{
"src": "data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🌿</text></svg>",
"sizes": "192x192",
"type": "image/svg+xml"
}
]
}

4
public/robots.txt Normal file
View File

@@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://hydro.thetempleofdoom.com/sitemap.xml

8
public/sitemap.xml Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://hydro.thetempleofdoom.com/</loc>
<changefreq>weekly</changefreq>
<priority>1.0</priority>
</url>
</urlset>

View File

@@ -43,6 +43,9 @@ export const query = {
// Initialize schema
export async function initDb() {
// Enable WAL mode for high concurrency
await query.run('PRAGMA journal_mode = WAL;');
// Users table
await query.run(`
CREATE TABLE IF NOT EXISTS users (

View File

@@ -1,5 +1,6 @@
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import { WebSocketServer } from 'ws';
import jwt from 'jsonwebtoken';
import bcrypt from 'bcryptjs';
@@ -23,6 +24,10 @@ initDb().catch((err) => {
});
// Middleware configuration
app.use(helmet({
contentSecurityPolicy: false,
crossOriginEmbedderPolicy: false,
}));
app.use(cors());
// Stripe Webhook needs raw body, configure other routes to use express.json()
@@ -392,7 +397,25 @@ const wss = new WebSocketServer({ server });
const rooms = new Map(); // projectRoom -> Set of ws clients
// Heartbeat to prevent memory leaks from dead connections
const interval = setInterval(() => {
wss.clients.forEach((ws) => {
if (ws.isAlive === false) return ws.terminate();
ws.isAlive = false;
ws.ping();
});
}, 30000);
wss.on('close', () => {
clearInterval(interval);
});
wss.on('connection', (ws) => {
ws.isAlive = true;
ws.on('pong', () => {
ws.isAlive = true;
});
let currentRoom = null;
let userId = null;

View File

@@ -0,0 +1,53 @@
import React, { Component, ErrorInfo, ReactNode } from 'react';
interface Props {
children?: ReactNode;
}
interface State {
hasError: boolean;
error?: Error;
}
export class ErrorBoundary extends Component<Props, State> {
public state: State = {
hasError: false
};
public static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('Uncaught error:', error, errorInfo);
}
public render() {
if (this.state.hasError) {
return (
<div className="min-h-screen bg-zinc-950 flex items-center justify-center p-4">
<div className="bg-zinc-900 border border-zinc-800 p-8 rounded-xl max-w-lg w-full shadow-2xl">
<h1 className="text-2xl font-bold text-rose-500 mb-4 flex items-center gap-3">
<span className="text-3xl"></span> System Crash Detected
</h1>
<p className="text-zinc-400 mb-6 leading-relaxed">
The physics engine or rendering canvas encountered an unrecoverable error.
We've caught the crash to prevent data loss.
</p>
<div className="bg-zinc-950 p-4 rounded-md mb-6 overflow-auto text-xs font-mono text-zinc-500 border border-zinc-800">
{this.state.error?.message || "Unknown Error"}
</div>
<button
onClick={() => window.location.reload()}
className="w-full py-3 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg font-medium transition-colors"
>
Restart Application
</button>
</div>
</div>
);
}
return this.props.children;
}
}

View File

@@ -457,6 +457,18 @@ function GrowTentCard({ part }: { part: PlacedPart }) {
</div>
<div className="text-[10px] text-zinc-400 space-y-1 pt-1.5 border-t border-zinc-800/60 font-mono">
<div className="flex justify-between">
<span>Air Exchange:</span>
<span className="text-zinc-200">{metrics.airChangesPerMinute.toFixed(2)} /min</span>
</div>
<div className="flex justify-between">
<span>Fresh Air:</span>
<span className="text-zinc-200">{metrics.netFreshAirCfm.toFixed(0)} CFM</span>
</div>
<div className="flex justify-between">
<span>Intake / Exhaust:</span>
<span className="text-zinc-200">{metrics.intakeCfm.toFixed(0)} / {metrics.exhaustCfm.toFixed(0)} CFM</span>
</div>
<div className="flex justify-between">
<span>Est. Temp:</span>
<span className="text-zinc-200">{metrics.roomTempF.toFixed(1)}°F</span>
@@ -515,7 +527,7 @@ function ReservoirCard({ part }: { part: PlacedPart }) {
</span>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="grid grid-cols-3 gap-2">
<div className="rounded-md bg-zinc-900/80 p-2 text-center">
<div className="text-[9px] text-zinc-500 font-bold uppercase">Runtime</div>
<div className={`mt-0.5 text-xs font-bold ${runtime?.empty ? 'text-rose-400' : runtime?.minutesRemaining !== null ? 'text-amber-400' : 'text-emerald-400'}`}>
@@ -545,6 +557,14 @@ function ReservoirCard({ part }: { part: PlacedPart }) {
</div>
<div className="text-[8px] text-zinc-600">Gallons</div>
</div>
<div className="rounded-md bg-zinc-900/80 p-2 text-center">
<div className="text-[9px] text-zinc-500 font-bold uppercase">Line Hold</div>
<div className="mt-0.5 text-xs font-bold text-zinc-200">
{runtime ? runtime.gallonsHeldInSystem.toFixed(2) : '0.00'}
</div>
<div className="text-[8px] text-zinc-600">Gal in plumbing</div>
</div>
</div>
{/* Water Stats Grid */}

View File

@@ -155,10 +155,10 @@ export function StatusPanel() {
</span>
</div>
<div className="mt-1 text-[11px] text-zinc-400 tabular-nums">
{room.volumeFt3.toFixed(0)} ft3 · {room.exhaustCfm.toFixed(0)} exhaust CFM
{room.volumeFt3.toFixed(0)} ft3 · {room.exhaustCfm.toFixed(0)} exhaust · {room.intakeCfm.toFixed(0)} intake CFM
</div>
<div className="text-[10px] text-zinc-500 tabular-nums">
{room.airChangesPerMinute.toFixed(2)} exchanges/min · {room.airChangesPerHour.toFixed(1)} ACH
{room.airChangesPerMinute.toFixed(2)} exchanges/min · {room.airChangesPerHour.toFixed(1)} ACH · {room.netFreshAirCfm.toFixed(0)} fresh-air CFM
</div>
<div className="mt-1 text-[10px] text-zinc-600 tabular-nums">
circ {room.circulationCfm.toFixed(0)} CFM · light {room.lightWatts.toFixed(0)} W · CO2 {room.co2TankCount} tank{room.co2TankCount === 1 ? '' : 's'}
@@ -205,6 +205,9 @@ export function StatusPanel() {
<span className="rounded bg-zinc-900 px-1.5 py-0.5 border border-zinc-800 text-amber-400">
{m.roomPPFD.toFixed(0)}
</span>
<span className="rounded bg-zinc-900 px-1.5 py-0.5 border border-zinc-800 text-zinc-300">
ACH {m.airChangesPerHour.toFixed(1)}
</span>
</div>
);
})()}
@@ -239,6 +242,14 @@ export function StatusPanel() {
</div>
) : null;
})()}
{(() => {
const runtime = sim.reservoirRuntime[stats.id];
return runtime && runtime.gallonsHeldInSystem > 0.02 ? (
<div className="mb-1 text-[9px] font-mono text-zinc-500">
{runtime.gallonsHeldInSystem.toFixed(2)} gal held in primed lines
</div>
) : null;
})()}
<div className="flex items-baseline justify-between">
<span className="text-xs font-semibold text-zinc-200">
{stats.label}

View File

@@ -6,6 +6,8 @@ export interface RoomMetrics {
sealed: boolean;
volumeFt3: number;
exhaustCfm: number;
intakeCfm: number;
netFreshAirCfm: number;
circulationCfm: number;
lightWatts: number;
co2TankCount: number;
@@ -49,6 +51,9 @@ export function computeRoomMetrics(partsMap: Record<string, PlacedPart>): RoomMe
const exhaustCfm = roomParts
.filter((part) => part.type === 'inlineFan' && (part.params.exhaust ?? 1) > 0)
.reduce((sum, part) => sum + (part.params.cfm ?? 0), 0);
const intakeCfm = roomParts
.filter((part) => part.type === 'inlineFan' && (part.params.exhaust ?? 1) <= 0)
.reduce((sum, part) => sum + (part.params.cfm ?? 0), 0);
const circulationCfm = roomParts
.filter((part) => part.type === 'circulationFan')
.reduce((sum, part) => sum + (part.params.cfm ?? 0), 0);
@@ -59,7 +64,15 @@ export function computeRoomMetrics(partsMap: Record<string, PlacedPart>): RoomMe
const co2Cfh = co2.reduce((sum, part) => sum + (part.params.regulatorCfh ?? 0), 0);
const volumeFt3 =
(room.params.width ?? 0) * (room.params.depth ?? 0) * (room.params.height ?? 0);
const airChangesPerMinute = volumeFt3 > 0 ? exhaustCfm / volumeFt3 : 0;
const isSealed = (room.params.sealed ?? 0) > 0;
const leakageAch = Math.max(0, room.params.leakageAch ?? (isSealed ? 0.15 : 0.5));
const leakageCfm = volumeFt3 > 0 ? (leakageAch * volumeFt3) / 60 : 0;
const balancedVentCfm = Math.min(exhaustCfm, intakeCfm);
const forcedExchangeCfm = isSealed
? Math.max(Math.abs(exhaustCfm - intakeCfm), leakageCfm)
: Math.max(exhaustCfm, intakeCfm);
const netFreshAirCfm = isSealed ? forcedExchangeCfm : Math.max(forcedExchangeCfm, balancedVentCfm);
const airChangesPerMinute = volumeFt3 > 0 ? netFreshAirCfm / volumeFt3 : 0;
const canopyArea = (room.params.width ?? 0) * (room.params.depth ?? 0);
const roomPPFD = canopyArea > 0 ? (lightWatts * 2.0) / canopyArea : 0;
@@ -79,23 +92,29 @@ export function computeRoomMetrics(partsMap: Record<string, PlacedPart>): RoomMe
const w = light.params.watts ?? 0;
return sum + (p > 0 ? w : 0);
}, 0);
const roomTempF = 75 + 0.01 * activeLightWatts;
const roomRH = room.params.humidity ?? 60;
const ambientTempF = room.params.ambientTempF ?? 75;
const ambientRh = room.params.humidity ?? 60;
const roomTempF = ambientTempF + 0.01 * activeLightWatts - (!isSealed ? netFreshAirCfm * 0.0025 : 0);
const roomRH = Math.max(20, Math.min(95, ambientRh + (isSealed ? 8 : 2) + activeLightWatts * 0.002 - netFreshAirCfm * 0.01));
const T_c = (roomTempF - 32) * 5 / 9;
const SVP = 0.61078 * Math.exp((17.27 * T_c) / (T_c + 237.3));
const roomVPD = SVP * (1 - roomRH / 100);
const isSealed = (room.params.sealed ?? 0) > 0;
const hasLightsOn = activeLightWatts > 0;
let roomCo2 = 400;
const ambientCo2 = room.params.ambientCo2 ?? 420;
let roomCo2 = ambientCo2;
const co2InjectionCfm = co2Cfh / 60;
if (isSealed && hasLightsOn) {
if (co2.length === 0) {
roomCo2 = 150;
} else {
roomCo2 = 1200;
const enrichmentPotential = volumeFt3 > 0 ? (co2InjectionCfm / volumeFt3) * 1_000_000 : 0;
const ventilationPenalty = airChangesPerMinute * 220;
roomCo2 = Math.max(ambientCo2, Math.min(1500, ambientCo2 + enrichmentPotential - ventilationPenalty));
}
} else if (co2.length > 0) {
roomCo2 = 1200;
const enrichmentPotential = volumeFt3 > 0 ? (co2InjectionCfm / volumeFt3) * 1_000_000 : 0;
roomCo2 = Math.max(ambientCo2, Math.min(1500, ambientCo2 + enrichmentPotential - airChangesPerMinute * 380));
}
// 1. Sensible Heat Gains (BTU/hr)
@@ -118,16 +137,16 @@ export function computeRoomMetrics(partsMap: Record<string, PlacedPart>): RoomMe
// Moisture removal via exhaust ventilation
let moistureVentPintsDay = 0;
if (!isSealed && exhaustCfm > 0) {
moistureVentPintsDay = Math.max(0, exhaustCfm * 1381 * (rho_w_room - rho_w_amb));
if (!isSealed && netFreshAirCfm > 0) {
moistureVentPintsDay = Math.max(0, netFreshAirCfm * 1381 * (rho_w_room - rho_w_amb));
}
const dehumidifierPintsDay = Math.max(0, transpirationPintsDay - moistureVentPintsDay);
// 4. AC Sizing Sizing (BTU/hr)
let ventSensibleBtuHr = 0;
if (!isSealed && exhaustCfm > 0) {
ventSensibleBtuHr = exhaustCfm * 1.08 * (roomTempF - 75);
if (!isSealed && netFreshAirCfm > 0) {
ventSensibleBtuHr = netFreshAirCfm * 1.08 * (roomTempF - ambientTempF);
}
const dehumHeatBtuHr = (dehumidifierPintsDay / 24) * 1200;
@@ -141,6 +160,8 @@ export function computeRoomMetrics(partsMap: Record<string, PlacedPart>): RoomMe
sealed: isSealed,
volumeFt3,
exhaustCfm,
intakeCfm,
netFreshAirCfm,
circulationCfm,
lightWatts,
co2TankCount: co2.length,

View File

@@ -1,10 +1,13 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import { ErrorBoundary } from './components/ErrorBoundary';
import './index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ErrorBoundary>
<App />
</ErrorBoundary>
</StrictMode>,
);

View File

@@ -314,12 +314,16 @@ export const PART_DEFS: Record<PartType, PartDefinition> = {
category: 'Environment',
description: 'Room/tent enclosure for planning air exchange and equipment',
color: '#475569',
defaults: { width: 7, depth: 5, height: 4, sealed: 0 },
defaults: { width: 7, depth: 5, height: 4, sealed: 0, ambientTempF: 75, humidity: 60, ambientCo2: 420, leakageAch: 0.15 },
params: {
width: { label: 'Width', min: 2, max: 20, step: 0.5, unit: 'ft' },
depth: { label: 'Depth', min: 2, max: 20, step: 0.5, unit: 'ft' },
height: { label: 'Height', min: 2, max: 12, step: 0.5, unit: 'ft' },
sealed: { label: 'Sealed room', min: 0, max: 1, step: 1 },
ambientTempF: { label: 'Ambient Temp', min: 45, max: 100, step: 1, unit: '°F' },
humidity: { label: 'Ambient RH', min: 20, max: 90, step: 1, unit: '%' },
ambientCo2: { label: 'Ambient CO₂', min: 250, max: 1200, step: 25, unit: 'PPM' },
leakageAch: { label: 'Leakage ACH', min: 0, max: 3, step: 0.05, unit: 'ACH' },
},
getConnectors: () => [],
},

View File

@@ -139,6 +139,45 @@ function waterPathLength(part: PlacedPart): number {
}
}
function cylinderGallons(lengthFt: number, diameterIn: number): number {
const radiusFt = Math.max(0.1, diameterIn) / 24;
return Math.PI * radiusFt * radiusFt * Math.max(0, lengthFt) * 7.48;
}
/** Approximate wet volume held by a part when fully primed. */
function waterHoldingGallons(part: PlacedPart): number {
const p = part.params;
switch (part.type) {
case 'pipe':
return cylinderGallons(p.length ?? 2, p.diameter ?? 1);
case 'coupling':
return cylinderGallons(0.6, p.diameter ?? 1);
case 'reducer':
return cylinderGallons(0.7, Math.min(p.diameterA ?? 1.5, p.diameterB ?? 1));
case 'cap':
return cylinderGallons(0.25, p.diameter ?? 1);
case 'elbow':
return cylinderGallons(waterPathLength(part), p.diameter ?? 1);
case 'tee':
return cylinderGallons(0.9, p.diameter ?? 1) * 1.18;
case 'valve':
return cylinderGallons(0.7, p.diameter ?? 1) * 1.1;
case 'pump':
return cylinderGallons(0.9, 1);
case 'tower':
return Math.max(0.2, (part.params.height ?? 4) * 0.22);
case 'tray':
return Math.max(0.1, (part.params.length ?? 3) * (part.params.width ?? 1.2) * 0.08 * 7.48);
case 'wallPanel':
return Math.max(0.2, (part.params.height ?? 4) * 0.18);
case 'emitter':
case 'drain':
return 0.03;
default:
return 0;
}
}
// ---------- main simulation ----------
interface GraphNode {
@@ -1207,6 +1246,26 @@ export function applyReservoirRuntime(
(pumpDrawByReservoir[pump.sourceReservoirId] ?? 0) + pump.gph;
}
const heldGallonsByReservoir: Record<string, number> = {};
for (const [partId, gph] of Object.entries(sim.flows)) {
if (gph <= 0.05) continue;
let cur: string | undefined = partId;
let sourceReservoirId: string | undefined;
let guard = 0;
while (cur && guard++ < 200) {
if (parts[cur]?.type === 'reservoir') {
sourceReservoirId = cur;
break;
}
cur = sim.upstream[cur];
}
if (!sourceReservoirId) continue;
const part = parts[partId];
if (!part || part.type === 'reservoir') continue;
heldGallonsByReservoir[sourceReservoirId] =
(heldGallonsByReservoir[sourceReservoirId] ?? 0) + waterHoldingGallons(part);
}
for (const reservoir of Object.values(parts)) {
if (reservoir.type !== 'reservoir') continue;
const initial = reservoirInitialGallons(reservoir);
@@ -1228,6 +1287,7 @@ export function applyReservoirRuntime(
const returned = sim.vesselIO[reservoir.id]?.inGph ?? 0;
const drawn = pumpDrawByReservoir[reservoir.id] ?? 0;
const netOutflowGph = Math.max(0, drawn - returned);
const heldGallons = heldGallonsByReservoir[reservoir.id] ?? 0;
if (simRunning && tick >= 0) {
const dtHours = Math.max(0, now - entry.lastTs) / 3_600_000;
@@ -1240,14 +1300,16 @@ export function applyReservoirRuntime(
}
reservoirRuntimeStore.set(reservoir.id, entry);
const empty = entry.current <= 0.01 && netOutflowGph > 0;
const availableGallons = Math.max(0, entry.current - heldGallons);
const empty = availableGallons <= 0.01 && (netOutflowGph > 0 || heldGallons > 0);
runtime[reservoir.id] = {
partId: reservoir.id,
gallonsInitial: entry.initial,
gallonsCurrent: entry.current,
gallonsCurrent: availableGallons,
gallonsCapacity: entry.capacity,
gallonsHeldInSystem: heldGallons,
netOutflowGph,
minutesRemaining: netOutflowGph > 0 ? (entry.current / netOutflowGph) * 60 : null,
minutesRemaining: netOutflowGph > 0 ? (availableGallons / netOutflowGph) * 60 : null,
empty,
};
}

View File

@@ -170,6 +170,7 @@ export interface ReservoirRuntimeStatus {
gallonsInitial: number;
gallonsCurrent: number;
gallonsCapacity: number;
gallonsHeldInSystem: number;
netOutflowGph: number;
minutesRemaining: number | null;
empty: boolean;

View File

@@ -12,4 +12,17 @@ export default defineConfig({
},
},
},
build: {
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes('node_modules')) {
if (id.includes('three')) return 'three-vendor';
if (id.includes('react')) return 'react-vendor';
return 'vendor';
}
},
},
},
},
});