Initial project import

This commit is contained in:
drjones
2026-06-13 17:36:44 -07:00
commit ad2a18cc8d
18471 changed files with 4497570 additions and 0 deletions

BIN
src/.DS_Store vendored Normal file

Binary file not shown.

440
src/App.tsx Normal file
View File

@@ -0,0 +1,440 @@
import { useEffect, useState, useMemo } from 'react';
import { SceneCanvas, clientToGround } from './components/SceneCanvas';
import { PartLibrary } from './components/PartLibrary';
import { PropertiesPanel } from './components/PropertiesPanel';
import { Toolbar } from './components/Toolbar';
import { StatusPanel } from './components/StatusPanel';
import { ColorPopover } from './components/ColorPopover';
import { BomPanel } from './bom';
import { DesignCheckPanel, validateSystem } from './validation';
import { getHotbarPartForKey } from './parts/hotbar';
import { useSimulation } from './simulation/flowSimulator';
import { snapValue, useBuilder } from './store/builderStore';
import { autosave, loadAutosave } from './utils/serializer';
import { demoParts } from './utils/demoProject';
import type { PartType } from './types';
import type { Finding } from './validation';
import { AuthProvider, useAuth } from './auth/AuthContext';
import { useCollab } from './hooks/useCollab';
import { LoginModal } from './auth/LoginModal';
import { ToastContainer, addToast } from './components/Toast';
import { useSaveStatus } from './store/saveStatusStore';
/** Restore the autosaved design, or seed the demo system on first visit. */
/** Restore the autosaved design, or seed the demo system on first visit. */
function useBootstrap(email?: string, loading?: boolean) {
const { user } = useAuth();
const hasUpgraded = user && user.purchased_slots > 0;
useEffect(() => {
if (loading) return;
const saved = hasUpgraded ? loadAutosave(email) : null;
if (saved && saved.parts.length > 0) {
useBuilder.getState().loadParts(saved.parts, saved.name);
} else {
useBuilder.getState().loadParts(demoParts(), 'Demo: pump loop');
}
}, [email, loading, hasUpgraded]);
}
/** Debounced autosave whenever parts change. */
function useAutosave(email?: string, loading?: boolean) {
const { user } = useAuth();
const hasUpgraded = user && user.purchased_slots > 0;
const setStatus = useSaveStatus((s) => s.setStatus);
useEffect(() => {
if (loading) return;
if (!hasUpgraded) {
setStatus('unsaved');
return;
}
let timer: ReturnType<typeof setTimeout> | undefined;
let hasUnsavedChanges = false;
const unsub = useBuilder.subscribe((s, prev) => {
if (s.parts !== prev.parts) {
hasUnsavedChanges = true;
setStatus('unsaved');
}
const isDragging = s.draggingId !== null || s.gizmoDragging;
const wasDragging = prev.draggingId !== null || prev.gizmoDragging;
const dragEnded = wasDragging && !isDragging;
const partsChangedWhileNotDragging = (s.parts !== prev.parts) && !isDragging;
if (hasUnsavedChanges && (dragEnded || partsChangedWhileNotDragging)) {
clearTimeout(timer);
setStatus('saving');
timer = setTimeout(() => {
autosave(useBuilder.getState().projectName, Object.values(useBuilder.getState().parts), email);
hasUnsavedChanges = false;
setStatus('saved');
}, 600);
} else if (isDragging) {
clearTimeout(timer);
timer = undefined;
}
});
return () => {
unsub();
clearTimeout(timer);
};
}, [email, loading, hasUpgraded, setStatus]);
}
/** Global keyboard shortcuts. */
function useShortcuts() {
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const target = e.target as HTMLElement;
if (/^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName)) return;
const s = useBuilder.getState();
const mod = e.metaKey || e.ctrlKey;
if (mod && e.key.toLowerCase() === 'z') {
e.preventDefault();
e.shiftKey ? s.redo() : s.undo();
return;
}
if (mod && e.key.toLowerCase() === 'y') {
e.preventDefault();
s.redo();
return;
}
if (mod && e.key.toLowerCase() === 'a') {
e.preventDefault();
s.selectAll();
return;
}
if (mod && e.key.toLowerCase() === 'c') {
const selectedParts = s.selectedIds.map(id => s.parts[id]).filter(Boolean);
if (selectedParts.length > 0) {
e.preventDefault();
navigator.clipboard.writeText(JSON.stringify({ app: 'hydro-builder-clip', parts: selectedParts }));
addToast(`Copied ${selectedParts.length} parts`, 'info');
}
return;
}
if (mod && e.key.toLowerCase() === 'v') {
e.preventDefault();
navigator.clipboard.readText().then((text) => {
try {
const data = JSON.parse(text);
if (data && data.app === 'hydro-builder-clip' && Array.isArray(data.parts)) {
const pastedIds = s.pasteParts(data.parts);
addToast(`Pasted ${pastedIds.length} parts`, 'success');
}
} catch (err) {
addToast('Clipboard does not contain valid hydro parts', 'error');
}
}).catch(() => {
addToast('Could not read clipboard', 'error');
});
return;
}
if (mod) return; // don't hijack other browser shortcuts
const hotbarType = getHotbarPartForKey(e.key);
if (hotbarType) {
e.preventDefault();
if (s.tool !== 'select') s.setTool('select');
s.setPlacingType(s.placingType === hotbarType ? null : hotbarType);
return;
}
switch (e.key) {
case ' ':
e.preventDefault(); // keep Space from scrolling / re-clicking buttons
s.toggleSimRunning();
break;
case 'Escape':
if (s.colorPopover) {
s.closeColorPopover();
} else {
s.setTool('select');
s.setSelected(null);
}
break;
case 'Delete':
case 'Backspace':
s.removeParts(s.selectedIds);
break;
case 'r':
case 'R':
s.rotateParts(s.selectedIds, 1, (e.shiftKey ? -1 : 1) * (Math.PI / 2));
break;
case 'd':
case 'D':
e.preventDefault();
s.duplicateParts(s.selectedIds);
break;
case 'q':
case 'Q':
s.translateParts(s.selectedIds, [0, -s.gridSize, 0], 'nudge');
break;
case 'e':
case 'E':
s.translateParts(s.selectedIds, [0, s.gridSize, 0], 'nudge');
break;
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, []);
}
export function BuilderApp() {
const { user, loading } = useAuth();
const email = user?.email;
useBootstrap(email, loading);
useAutosave(email, loading);
useShortcuts();
const { remoteCursors } = useCollab();
const placingType = useBuilder((s) => s.placingType);
const tool = useBuilder((s) => s.tool);
const selectedCount = useBuilder((s) => s.selectedIds.length);
const viewMode = useBuilder((s) => s.viewMode);
const renderMode = useBuilder((s) => s.renderMode);
const parts = useBuilder((s) => s.parts);
const activeRoomId = useBuilder((s) => s.activeRoomId);
const sim = useSimulation();
const [bomOpen, setBomOpen] = useState(false);
const [checkFindings, setCheckFindings] = useState<Finding[] | null>(null);
const roomParts = useMemo(() => {
return Object.values(parts).filter((p) => p.roomId === activeRoomId);
}, [parts, activeRoomId]);
const runDesignCheck = () => {
setCheckFindings(validateSystem(roomParts, sim));
};
const applyFindingFix = (finding: Finding) => {
const s = useBuilder.getState();
if (finding.id.startsWith('dead-leg:')) {
s.fixDeadLeg(finding.partIds);
} else if (finding.id.startsWith('diameter-mismatch:')) {
s.fixDiameterMismatch(finding.partIds);
} else if (finding.id.startsWith('pump-dead:')) {
const pumpId = finding.partIds[0];
if (pumpId) {
const p = s.parts[pumpId];
if (p && (p.params.on === 0 || p.params.enabled === 0)) {
s.updatePart(pumpId, { params: { ...p.params, on: 1, enabled: 1 } });
} else {
s.fixPumpDeadEnd(pumpId);
}
}
} else if (finding.id.startsWith('pump-head:')) {
const pumpId = finding.partIds[0];
if (pumpId) {
const p = s.parts[pumpId];
if (p) {
const requiredHead = Math.ceil((p.params.maxHeadFt || 8) * 1.5);
s.updatePart(pumpId, { params: { ...p.params, maxHeadFt: Math.min(30, requiredHead) } });
}
}
} else if (finding.id.startsWith('unsupported-span:')) {
const pipeId = finding.partIds[0];
if (pipeId) {
const p = s.parts[pipeId];
if (p) {
const y = p.position[1];
const supportHeight = Math.max(0.5, y - 0.1);
const latticeId = s.addPart('lattice', [p.position[0], supportHeight / 2, p.position[2]], { snap: false });
const lattice = useBuilder.getState().parts[latticeId];
if (lattice) {
s.updatePart(latticeId, { params: { ...lattice.params, height: supportHeight } });
}
}
}
} else if (finding.id.startsWith('drain-capacity:')) {
const drainId = finding.partIds[0];
if (drainId) {
const p = s.parts[drainId];
if (p) {
const reported = sim.drains?.find((d) => d?.partId === drainId)?.inflowGph;
const inflow = reported ?? sim.flows?.[drainId] ?? 0;
const needed = Math.ceil(inflow / 25) * 25;
s.updatePart(drainId, { params: { ...p.params, capacityGph: Math.min(2000, needed) } });
}
}
} else if (finding.id.startsWith('res-temp-high:')) {
const chillers = Object.values(s.parts).filter((p) => p.type === 'waterChiller');
for (const chiller of chillers) {
s.updatePart(chiller.id, { params: { ...chiller.params, on: 1, targetTemp: 68 } });
}
} else if (finding.id.startsWith('res-ph-low:') || finding.id.startsWith('res-ph-high:')) {
const resId = finding.partIds[0];
if (resId) {
const p = s.parts[resId];
if (p) s.updatePart(resId, { params: { ...p.params, ph: 6.0 } });
}
} else if (finding.id.startsWith('res-ec-low:') || finding.id.startsWith('res-ec-high:')) {
const resId = finding.partIds[0];
if (resId) {
const p = s.parts[resId];
if (p) s.updatePart(resId, { params: { ...p.params, ec: 1.2 } });
}
} else if (finding.id.startsWith('room-co2-open:') || finding.id.startsWith('room-co2-missing:') || finding.id.startsWith('room-co2-starved:')) {
const tentId = finding.partIds[0];
if (tentId) {
const p = s.parts[tentId];
if (p) {
// If the CO2 open warning is active, seal the room. If CO2 is starved/missing, unseal the room to swap with fresh air.
const isCo2Open = finding.id.startsWith('room-co2-open:');
s.updatePart(tentId, { params: { ...p.params, sealed: isCo2Open ? 1 : 0 } });
}
}
} else if (finding.id.startsWith('room-sealed-exhaust:')) {
const tentId = finding.partIds[0];
if (tentId) {
const p = s.parts[tentId];
if (p) s.updatePart(tentId, { params: { ...p.params, sealed: 0 } });
}
} else if (finding.id.startsWith('room-vent-low:')) {
const fans = Object.values(s.parts).filter((p) => p.type === 'inlineFan');
for (const fan of fans) {
s.updatePart(fan.id, { params: { ...fan.params, cfm: 600 } });
}
} else if (finding.id.startsWith('room-light-low:')) {
const lights = Object.values(s.parts).filter((p) => p.type === 'growLight');
for (const light of lights) {
s.updatePart(light.id, { params: { ...light.params, watts: Math.min(1000, (light.params.watts ?? 650) + 150) } });
}
} else if (finding.id.startsWith('room-light-high:')) {
const lights = Object.values(s.parts).filter((p) => p.type === 'growLight');
for (const light of lights) {
s.updatePart(light.id, { params: { ...light.params, watts: Math.max(100, (light.params.watts ?? 650) - 200) } });
}
} else if (finding.id.startsWith('room-dli-low:')) {
const lights = Object.values(s.parts).filter((p) => p.type === 'growLight');
for (const light of lights) {
s.updatePart(light.id, { params: { ...light.params, photoperiod: Math.min(24, (light.params.photoperiod ?? 18) + 4) } });
}
} else if (finding.id.startsWith('room-dli-high:')) {
const lights = Object.values(s.parts).filter((p) => p.type === 'growLight');
for (const light of lights) {
s.updatePart(light.id, { params: { ...light.params, photoperiod: Math.max(0, (light.params.photoperiod ?? 18) - 6) } });
}
} else if (finding.id.startsWith('room-vpd-low:')) {
const lights = Object.values(s.parts).filter((p) => p.type === 'growLight');
for (const light of lights) {
s.updatePart(light.id, { params: { ...light.params, watts: Math.min(1000, (light.params.watts ?? 650) + 150) } });
}
} else if (finding.id.startsWith('room-vpd-high:')) {
const lights = Object.values(s.parts).filter((p) => p.type === 'growLight');
for (const light of lights) {
s.updatePart(light.id, { params: { ...light.params, watts: Math.max(100, (light.params.watts ?? 650) - 150) } });
}
} else if (finding.id.startsWith('closed-valve:')) {
const valveId = finding.partIds[0];
if (valveId) {
const p = s.parts[valveId];
if (p) s.updatePart(valveId, { params: { ...p.params, open: 100 } });
}
} else if (finding.id.startsWith('part-overlap:')) {
const ids = finding.partIds;
if (ids.length >= 2) {
s.removeParts([ids[1]]);
}
} else if (finding.id.startsWith('orphan:')) {
s.removeParts(finding.partIds);
}
setTimeout(() => {
const nextParts = Object.values(useBuilder.getState().parts);
setCheckFindings(validateSystem(nextParts, sim));
}, 50);
};
/** Handle HTML5 drag-and-drop from the parts library onto the canvas. */
const onDrop = (e: React.DragEvent) => {
const type = e.dataTransfer.getData('application/x-hydro-part') as PartType;
if (!type) return;
e.preventDefault();
const ground = clientToGround(e.clientX, e.clientY);
if (!ground) return;
const s = useBuilder.getState();
const snap = (v: number) => (s.snapToGrid ? snapValue(v, s.gridSize) : v);
const y = ['pipe', 'elbow', 'tee', 'valve', 'emitter'].includes(type) ? 0.25 : 0;
s.addPart(type, [snap(ground[0]), y, snap(ground[2])]);
};
if (loading) {
return (
<div className="flex h-screen flex-col items-center justify-center bg-zinc-950 text-zinc-100">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-sky-500 border-t-transparent mb-4" />
<span className="text-sm font-semibold tracking-wider text-zinc-400">Hydro Builder initializing...</span>
</div>
);
}
if (!user) {
return <LoginModal />;
}
return (
<div className="flex h-screen flex-col bg-zinc-950 text-zinc-100 select-none">
<Toolbar onOpenBom={() => setBomOpen(true)} onCheckBuild={runDesignCheck} />
<div className="flex min-h-0 flex-1">
<PartLibrary />
<main
className="relative min-w-0 flex-1"
onDragOver={(e) => e.preventDefault()}
onDrop={onDrop}
>
<SceneCanvas remoteCursors={remoteCursors} />
{/* Contextual hint overlay */}
{(placingType || tool === 'measure' || tool === 'pipeRun') && (
<div className="pointer-events-none absolute top-3 left-1/2 -translate-x-1/2 rounded-full border border-sky-800 bg-sky-950/90 px-4 py-1.5 text-xs font-medium text-sky-200 shadow-lg">
{placingType
? 'Click to place · Shift-click to stamp copies · 1-9 switches parts · Esc to cancel'
: tool === 'pipeRun'
? 'Pipe Run: click points · Shift-click to keep routing · Esc to exit'
: 'Measure: click two points · Esc to exit'}
</div>
)}
{/* Selection hint */}
{!placingType && tool !== 'measure' && selectedCount > 0 && (
<div className="pointer-events-none absolute bottom-3 left-1/2 -translate-x-1/2 rounded-full border border-zinc-800 bg-zinc-950/85 px-4 py-1.5 text-[11px] font-medium text-zinc-400 shadow-lg backdrop-blur">
{selectedCount > 1
? `${selectedCount} parts — drag moves all · ⌥drag clones · ⇧click adds/removes · ⌘click color · D duplicate · ⌫ delete`
: `Drag body or gizmo to move · double-click plumbing to grab the whole run · selected pipes show stretch handles · drag a connector dot to snap${viewMode === 'orbit' ? ' · ⇧drag for vertical' : ''} · ${renderMode === 'xray' ? 'X-Ray shows internals' : renderMode === 'water' ? 'Water mode isolates flow' : 'Builder mode keeps solids primary'} · ⌥drag clones · ⌘click color · R rotate`}
</div>
)}
<ColorPopover />
</main>
<PropertiesPanel />
</div>
<StatusPanel />
{bomOpen && <BomPanel onClose={() => setBomOpen(false)} />}
{checkFindings && (
<DesignCheckPanel
findings={checkFindings}
onSelectPart={(id) => {
const s = useBuilder.getState();
s.setSelected(id);
if (id) {
const part = s.parts[id];
if (part) s.setFocusTarget(part.position);
}
}}
onApplyFix={applyFindingFix}
onClose={() => setCheckFindings(null)}
/>
)}
<ToastContainer />
</div>
);
}
export default function App() {
return (
<AuthProvider>
<BuilderApp />
</AuthProvider>
);
}

172
src/auth/AuthContext.tsx Normal file
View File

@@ -0,0 +1,172 @@
import React, { createContext, useState, useEffect, useContext } from 'react';
import { addToast } from '../components/Toast';
export interface User {
id: number;
email: string;
purchased_slots: number;
total_slots: number;
project_count: number;
}
interface AuthContextType {
token: string | null;
user: User | null;
loading: boolean;
error: string | null;
login: (email: string, password: string) => Promise<void>;
signup: (email: string, password: string) => Promise<void>;
logout: () => void;
refreshProfile: () => Promise<void>;
clearError: () => void;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [token, setToken] = useState<string | null>(localStorage.getItem('hydro-token'));
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
// Fetch user details when token changes or on load
const fetchUser = async (authToken: string) => {
try {
const response = await fetch('/api/auth/me', {
headers: {
Authorization: `Bearer ${authToken}`
}
});
if (response.ok) {
const data = await response.json();
setUser(data.user);
} else {
// Token might be invalid or expired
logout();
}
} catch (err) {
console.error('Error fetching user info:', err);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (token) {
fetchUser(token);
} else {
setUser(null);
setLoading(false);
}
// Handle Stripe redirect URL parameters
const params = new URLSearchParams(window.location.search);
if (params.get('checkout_success') === 'true') {
const url = new URL(window.location.href);
url.searchParams.delete('checkout_success');
window.history.replaceState({}, document.title, url.pathname);
addToast('Payment successful! Your Premium Upgrade has been unlocked.', 'success');
if (token) {
fetchUser(token);
}
} else if (params.get('checkout_cancel') === 'true') {
const url = new URL(window.location.href);
url.searchParams.delete('checkout_cancel');
window.history.replaceState({}, document.title, url.pathname);
addToast('Payment cancelled.', 'info');
}
}, [token]);
const login = async (email: string, password: string) => {
setError(null);
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ email, password })
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Login failed');
}
localStorage.setItem('hydro-token', data.token);
setToken(data.token);
setUser(data.user);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown login error');
throw err;
}
};
const signup = async (email: string, password: string) => {
setError(null);
try {
const response = await fetch('/api/auth/signup', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ email, password })
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Signup failed');
}
localStorage.setItem('hydro-token', data.token);
setToken(data.token);
setUser(data.user);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown signup error');
throw err;
}
};
const logout = () => {
localStorage.removeItem('hydro-token');
setToken(null);
setUser(null);
};
const refreshProfile = async () => {
if (token) {
await fetchUser(token);
}
};
const clearError = () => {
setError(null);
};
return (
<AuthContext.Provider
value={{
token,
user,
loading,
error,
login,
signup,
logout,
refreshProfile,
clearError
}}
>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};

209
src/auth/LoginModal.tsx Normal file
View File

@@ -0,0 +1,209 @@
import React, { useState } from 'react';
import { useAuth } from './AuthContext';
export const LoginModal: React.FC = () => {
const { login, signup, error, clearError } = useAuth();
const [isSignUp, setIsSignUp] = useState(false);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [uiError, setUiError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!email || !password) {
setUiError('Please fill in all fields');
return;
}
setLoading(true);
setUiError(null);
clearError();
try {
if (isSignUp) {
await signup(email, password);
} else {
await login(email, password);
}
} catch (err) {
setUiError(err instanceof Error ? err.message : 'Authentication failed');
} finally {
setLoading(false);
}
};
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-zinc-950 overflow-y-auto px-4 py-8">
{/* Background radial accent */}
<div className="absolute top-0 left-0 right-0 bottom-0 bg-[radial-gradient(circle_at_30%_20%,rgba(14,165,233,0.1),transparent_40%)] pointer-events-none" />
<div className="w-full max-w-5xl grid grid-cols-1 lg:grid-cols-12 gap-8 lg:gap-12 items-center relative z-10 my-auto">
{/* Left Side: Product Feature Hype */}
<div className="lg:col-span-7 flex flex-col text-left space-y-6">
<div className="flex items-center gap-2">
<span className="grid h-8 w-8 place-items-center rounded-lg bg-gradient-to-br from-sky-400 to-blue-600 text-sm font-black text-white shadow-md shadow-sky-500/10">
H
</span>
<span className="text-sm font-bold tracking-wider text-sky-400 uppercase">
Hydro Builder
</span>
</div>
<h1 className="text-3xl sm:text-4xl lg:text-5xl font-black text-white leading-tight tracking-tight">
Stop Guessing. <br className="hidden sm:inline" />
<span className="bg-gradient-to-r from-sky-400 to-blue-500 bg-clip-text text-transparent">
Precision-Plumb
</span> Your Grow.
</h1>
<p className="text-sm sm:text-base text-zinc-400 max-w-xl leading-relaxed">
Eliminate design errors, head-loss issues, and unnecessary trips to the hardware store. Design, simulate, and size your entire water system in full interactive 3D.
</p>
{/* Feature Teasers List */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5 pt-2">
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<span className="text-base">💧</span>
<h3 className="text-sm font-bold text-zinc-200">Real-Time Fluid Simulation</h3>
</div>
<p className="text-xs text-zinc-400 leading-relaxed pl-6">
Instant calculations for GPH flow rates, water velocities (ft/s), and system pressures.
</p>
</div>
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<span className="text-base">📋</span>
<h3 className="text-sm font-bold text-zinc-200">BOM & Cut List Generator</h3>
</div>
<p className="text-xs text-zinc-400 leading-relaxed pl-6">
One-click material summaries of every fitting, adapter, and pipe length. Your design is your shopping list.
</p>
</div>
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<span className="text-base">📐</span>
<h3 className="text-sm font-bold text-zinc-200">Auto-Snapping & Route Tools</h3>
</div>
<p className="text-xs text-zinc-400 leading-relaxed pl-6">
Automatic elbow insertions, grid snaps, and open-ended pipe caps. Plumb runs in seconds.
</p>
</div>
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<span className="text-base">🔍</span>
<h3 className="text-sm font-bold text-zinc-200">Pro Design Check Diagnostics</h3>
</div>
<p className="text-xs text-zinc-400 leading-relaxed pl-6">
Automated error checks for dead-legs, backwards slopes, and diameter size mismatches.
</p>
</div>
</div>
{/* Value Prop Banner */}
<div className="border border-zinc-800 bg-zinc-900/30 rounded-xl p-4 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 max-w-xl">
<div>
<span className="text-[10px] font-bold text-sky-400 uppercase tracking-widest block">Pricing model</span>
<span className="text-sm font-bold text-zinc-200">1 free workspace forever</span>
</div>
<div className="hidden sm:block h-6 w-px bg-zinc-800" />
<div>
<span className="text-[10px] font-bold text-emerald-400 uppercase tracking-widest block">Unlimited building</span>
<span className="text-sm font-bold text-zinc-200">Only $5 per additional build slot</span>
</div>
</div>
</div>
{/* Right Side: Auth Panel Card */}
<div className="lg:col-span-5 w-full flex justify-center">
<div className="w-full max-w-md overflow-hidden rounded-2xl border border-zinc-850 bg-zinc-900/80 p-8 shadow-2xl backdrop-blur-md">
<div className="flex flex-col items-center mb-6 text-center">
<h2 className="text-xl font-bold text-zinc-100">
{isSignUp ? 'Create your free account' : 'Welcome Back'}
</h2>
<p className="text-xs text-zinc-400 mt-1">
{isSignUp ? 'Get instant access to your free build slot' : 'Sign in to access your designs'}
</p>
</div>
{(uiError || error) && (
<div className="mb-4 rounded-lg border border-red-500/20 bg-red-500/10 p-3 text-xs text-red-400">
{uiError || error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-xs font-semibold text-zinc-400 mb-1" htmlFor="email">
Email Address
</label>
<input
id="email"
type="email"
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full rounded-lg border border-zinc-800 bg-zinc-950 px-3.5 py-2.5 text-sm text-zinc-200 outline-none transition focus:border-sky-500 focus:bg-zinc-950"
placeholder="you@example.com"
required
/>
</div>
<div>
<label className="block text-xs font-semibold text-zinc-400 mb-1" htmlFor="password">
Password
</label>
<input
id="password"
type="password"
autoComplete={isSignUp ? 'new-password' : 'current-password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full rounded-lg border border-zinc-800 bg-zinc-950 px-3.5 py-2.5 text-sm text-zinc-200 outline-none transition focus:border-sky-500 focus:bg-zinc-950"
placeholder="••••••••"
required
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full rounded-lg bg-gradient-to-r from-sky-500 to-blue-600 py-3 text-sm font-semibold text-white transition hover:from-sky-400 hover:to-blue-500 disabled:opacity-50 flex items-center justify-center gap-2 cursor-pointer shadow-lg shadow-sky-500/10"
>
{loading ? (
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
) : isSignUp ? (
'Sign Up & Build'
) : (
'Sign In'
)}
</button>
</form>
<div className="mt-5 text-center">
<button
type="button"
onClick={() => {
setIsSignUp(!isSignUp);
setUiError(null);
clearError();
}}
className="text-xs font-medium text-sky-400 hover:text-sky-300 transition"
>
{isSignUp ? 'Already have an account? Sign In' : "Don't have an account? Sign Up"}
</button>
</div>
</div>
</div>
</div>
</div>
);
};

321
src/bom/BomPanel.tsx Normal file
View File

@@ -0,0 +1,321 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useBuilder } from '../store/builderStore';
import { computeBom, formatDiameter, formatInches, formatMoney } from './computeBom';
import { bomToText, downloadBomCsv } from './csv';
import { downloadBomPdf } from './pdf';
import type { BomLine, CutList } from './types';
import { exportToGLTF, exportToOBJ } from '../utils/cadExporter';
/**
* BomPanel — self-contained Bill of Materials drawer.
*
* Renders a fixed-position overlay (no portals) with a right-hand drawer:
* summary header, per-category line-item table, expandable cut lists, and
* Export CSV / Copy as text / Print actions. Recomputes via useMemo whenever
* the parts in the builder store change.
*
* Mount it conditionally, e.g.:
* {bomOpen && <BomPanel onClose={() => setBomOpen(false)} />}
*/
export function BomPanel({ onClose }: { onClose: () => void }) {
const parts = useBuilder((s) => s.parts);
const activeRoomId = useBuilder((s) => s.activeRoomId);
const projectName = useBuilder((s) => s.projectName);
const scene = useBuilder((s) => s.scene);
const roomParts = useMemo(() => {
return Object.values(parts).filter((p) => p.roomId === activeRoomId);
}, [parts, activeRoomId]);
const bom = useMemo(() => computeBom(roomParts), [roomParts]);
const [copied, setCopied] = useState(false);
const copyTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
return () => {
window.removeEventListener('keydown', onKey);
if (copyTimer.current) clearTimeout(copyTimer.current);
};
}, [onClose]);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(bomToText(bom));
setCopied(true);
if (copyTimer.current) clearTimeout(copyTimer.current);
copyTimer.current = setTimeout(() => setCopied(false), 1500);
} catch {
/* clipboard unavailable (permissions) — ignore */
}
};
const handlePrint = () => {
const win = window.open('', '_blank', 'width=720,height=900');
if (!win) return;
const esc = (s: string) =>
s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
win.document.write(
`<!doctype html><title>${esc(projectName)} — Bill of Materials</title>` +
`<pre style="font: 12px/1.5 ui-monospace, Menlo, monospace; padding: 24px;">` +
esc(bomToText(bom)) +
`</pre>`,
);
win.document.close();
win.focus();
win.print();
};
// Group lines by category for sectioned rendering.
const sections = useMemo(() => {
const map = new Map<string, BomLine[]>();
for (const line of bom.lines) {
const arr = map.get(line.category);
if (arr) arr.push(line);
else map.set(line.category, [line]);
}
return [...map.entries()];
}, [bom]);
const subtotalOf = (name: string) =>
bom.categories.find((c) => c.name === name)?.subtotal ?? 0;
return (
<div className="fixed inset-0 z-[100] flex justify-end">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/60 backdrop-blur-[2px]"
onClick={onClose}
aria-hidden
/>
{/* Drawer */}
<div className="relative flex h-full w-full max-w-xl flex-col border-l border-zinc-800 bg-zinc-950 shadow-2xl">
{/* Header */}
<div className="shrink-0 border-b border-zinc-800 px-5 py-4">
<div className="flex items-start justify-between">
<div>
<h2 className="text-sm font-bold tracking-wide text-zinc-100">
Bill of Materials
</h2>
<p className="mt-0.5 text-xs text-zinc-500">{projectName}</p>
</div>
<button
onClick={onClose}
title="Close (Esc)"
className="rounded-md px-2 py-1 text-sm text-zinc-400 transition hover:bg-zinc-800 hover:text-zinc-100"
>
</button>
</div>
<div className="mt-3 flex items-end justify-between">
<div>
<div className="text-2xl font-bold text-sky-400 tabular-nums">
{formatMoney(bom.totalCost)}
<span className="ml-1.5 text-xs font-medium text-zinc-500">est. total</span>
</div>
<div className="text-[11px] text-zinc-500 tabular-nums">
{bom.partCount} parts · {bom.lines.length} line items
{bom.cutLists.length > 0 &&
` · ${bom.cutLists.reduce((s, c) => s + c.sticksToBuy, 0)} PVC sticks`}
</div>
</div>
<div className="flex flex-wrap gap-1.5 justify-end">
<ActionBtn onClick={() => downloadBomCsv(bom, projectName)} title="Download as CSV">
Export CSV
</ActionBtn>
<ActionBtn onClick={() => downloadBomPdf(bom, projectName)} title="Download as PDF">
Export PDF
</ActionBtn>
<ActionBtn onClick={handleCopy} title="Copy shopping list to clipboard">
{copied ? 'Copied' : 'Copy as text'}
</ActionBtn>
<ActionBtn onClick={handlePrint} title="Print shopping list">
Print
</ActionBtn>
<ActionBtn
onClick={() => scene && exportToGLTF(scene, `${projectName}.gltf`)}
title={scene ? "Export 3D layout as glTF" : "Scene loading..."}
disabled={!scene}
>
Export glTF
</ActionBtn>
<ActionBtn
onClick={() => scene && exportToOBJ(scene, `${projectName}.obj`)}
title={scene ? "Export 3D layout as OBJ" : "Scene loading..."}
disabled={!scene}
>
Export OBJ
</ActionBtn>
</div>
</div>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto px-5 py-4">
{bom.partCount === 0 ? (
<p className="py-10 text-center text-xs text-zinc-600">
Nothing here yet place some parts in the scene to build a shopping list.
</p>
) : (
<>
{sections.map(([category, lines]) => (
<section key={category} className="mb-5">
<div className="mb-1.5 flex items-baseline justify-between">
<h3 className="text-[10px] font-bold tracking-wider text-zinc-500 uppercase">
{category}
</h3>
<span className="text-[11px] font-semibold text-zinc-400 tabular-nums">
{formatMoney(subtotalOf(category))}
</span>
</div>
<div className="overflow-hidden rounded-lg border border-zinc-800">
{lines.map((line, i) => (
<div
key={line.key}
className={`flex items-start justify-between gap-3 px-3 py-2 ${
i % 2 === 1 ? 'bg-zinc-900/40' : 'bg-zinc-900/10'
}`}
>
<div className="min-w-0">
<div className="text-xs font-medium text-zinc-200">
{line.description}
{line.qty > 1 || line.unit === 'stick' ? (
<span className="ml-1.5 text-zinc-500">×{line.qty}</span>
) : null}
{line.estimated && (
<span className="ml-1.5 rounded bg-amber-950/80 px-1 py-px text-[9px] font-semibold text-amber-400 ring-1 ring-amber-900">
est.
</span>
)}
</div>
{line.spec && (
<div className="mt-0.5 text-[10px] text-zinc-500">{line.spec}</div>
)}
</div>
<div className="shrink-0 text-right">
<div className="text-xs font-semibold text-zinc-200 tabular-nums">
{formatMoney(line.totalCost)}
</div>
{line.qty > 1 && (
<div className="text-[10px] text-zinc-500 tabular-nums">
{formatMoney(line.unitCost)} / {line.unit}
</div>
)}
</div>
</div>
))}
</div>
</section>
))}
{bom.cutLists.length > 0 && (
<section className="mb-5">
<h3 className="mb-1.5 text-[10px] font-bold tracking-wider text-zinc-500 uppercase">
Cut list 10 ft sticks
</h3>
<div className="flex flex-col gap-2">
{bom.cutLists.map((list) => (
<CutListCard key={list.diameterIn} list={list} />
))}
</div>
</section>
)}
</>
)}
</div>
{/* Footer */}
<div className="shrink-0 border-t border-zinc-800 px-5 py-3">
<div className="flex items-center justify-between text-xs">
<span className="text-zinc-500">
Prices are rough US retail estimates, not quotes.
</span>
<span className="font-bold text-zinc-100 tabular-nums">
{formatMoney(bom.totalCost)}
</span>
</div>
</div>
</div>
</div>
);
}
/** Expandable per-diameter cut plan card. */
function CutListCard({ list }: { list: CutList }) {
const [open, setOpen] = useState(false);
return (
<div className="overflow-hidden rounded-lg border border-zinc-800 bg-zinc-900/30">
<button
onClick={() => setOpen((o) => !o)}
className="flex w-full items-center justify-between px-3 py-2 text-left transition hover:bg-zinc-800/50"
>
<span className="text-xs font-semibold text-zinc-200">
{formatDiameter(list.diameterIn)} PVC
<span className="ml-2 font-normal text-zinc-500">
buy {list.sticksToBuy} stick{list.sticksToBuy === 1 ? '' : 's'} · {list.cuts.length}{' '}
cuts · {formatInches(list.totalLeftoverIn)} scrap
</span>
</span>
<span className="text-[10px] text-zinc-500">{open ? 'Hide' : 'Show'}</span>
</button>
{open && (
<div className="border-t border-zinc-800 px-3 py-2">
{list.sticks.map((stick) => (
<div key={stick.index} className="flex items-baseline gap-2 py-0.5">
<span className="w-14 shrink-0 text-[10px] font-semibold text-zinc-500">
Stick {stick.index}
</span>
<span className="text-[11px] text-zinc-300 tabular-nums">
{stick.cuts.map((c) => formatInches(c)).join(', ')}
<span className="ml-1.5 text-zinc-500">
{formatInches(stick.leftoverIn)} left
</span>
</span>
</div>
))}
{list.spliceCount > 0 && (
<p className="mt-1 text-[10px] text-amber-400/90">
{list.spliceCount} coupling{list.spliceCount === 1 ? '' : 's'} needed to splice runs
longer than 10 ft.
</p>
)}
</div>
)}
</div>
);
}
function ActionBtn({
children,
onClick,
title,
disabled,
}: {
children: React.ReactNode;
onClick: () => void;
title?: string;
disabled?: boolean;
}) {
return (
<button
onClick={onClick}
title={title}
disabled={disabled}
className={`rounded-md px-2.5 py-1.5 text-xs font-medium transition ${
disabled
? 'bg-zinc-800 text-zinc-500 ring-1 ring-zinc-700 cursor-not-allowed opacity-50'
: 'bg-sky-500/15 text-sky-300 ring-1 ring-sky-700 hover:bg-sky-500/25'
}`}
>
{children}
</button>
);
}

344
src/bom/computeBom.ts Normal file
View File

@@ -0,0 +1,344 @@
import type { PlacedPart } from '../types';
import { PART_DEFS } from '../parts/catalog';
import {
STICK_LENGTH_IN,
UNKNOWN_PART_PRICE,
cuFtToGallons,
equipmentPrice,
fittingPrice,
pvcStickPrice,
round2,
} from './prices';
import type { Bom, BomLine, CutList, CutStick } from './types';
/**
* computeBom — pure function turning placed parts into a shopping list,
* per-diameter PVC cut plans, and a cost estimate.
*
* Grouping rules:
* - `pipe` parts are aggregated per diameter into a CutList (10-ft sticks,
* first-fit-decreasing bin packing) and one "sticks to buy" line.
* - Fitting-like parts (elbow, tee, valve, ... anything small with a
* `diameter` param) are grouped by type + diameter (+ angle for elbows).
* - Equipment/grow parts are grouped by type + full param signature so
* identical units collapse into one line with their key specs.
* - Unknown part types never crash: they get the catalog label/category if
* present at runtime, otherwise a prettified type name and "Other", and a
* flat fallback price flagged as estimated.
*/
export function computeBom(parts: PlacedPart[]): Bom {
const lines: BomLine[] = [];
const pipesByDiameter = new Map<number, PlacedPart[]>();
const groups = new Map<string, PlacedPart[]>();
for (const part of parts) {
if (part.type === 'pipe') {
const d = part.params.diameter ?? 1;
pushMap(pipesByDiameter, d, part);
} else {
pushMap(groups, groupKey(part), part);
}
}
// --- Pipe lines + cut lists ---
const cutLists: CutList[] = [];
for (const [diameter, pipes] of [...pipesByDiameter.entries()].sort((a, b) => a[0] - b[0])) {
const cutList = buildCutList(diameter, pipes);
cutLists.push(cutList);
const unitCost = pvcStickPrice(diameter);
lines.push({
key: `pipe:${diameter}`,
partType: 'pipe',
category: categoryOf('pipe'),
description: `${formatDiameter(diameter)} PVC Pipe — 10 ft stick`,
spec: `${formatInches(cutList.totalCutIn)} total run, ${cutList.cuts.length} cuts across ${pipes.length} pipe${pipes.length === 1 ? '' : 's'}`,
qty: cutList.sticksToBuy,
unit: 'stick',
unitCost,
totalCost: round2(unitCost * cutList.sticksToBuy),
estimated: false,
partIds: pipes.map((p) => p.id),
});
// Runs longer than one stick need couplings to splice the pieces.
if (cutList.spliceCount > 0) {
const couplingCost = fittingPrice('coupling', diameter) ?? 1;
lines.push({
key: `coupling:${diameter}`,
partType: 'coupling',
category: categoryOf('pipe'),
description: `${formatDiameter(diameter)} PVC Coupling`,
spec: 'splices for runs longer than 10 ft',
qty: cutList.spliceCount,
unit: 'each',
unitCost: couplingCost,
totalCost: round2(couplingCost * cutList.spliceCount),
estimated: false,
partIds: [],
});
}
}
// --- Everything else ---
for (const members of groups.values()) {
lines.push(buildLine(members));
}
sortLines(lines);
// --- Rollups ---
const byCategory = new Map<string, number>();
for (const line of lines) {
byCategory.set(line.category, (byCategory.get(line.category) ?? 0) + line.totalCost);
}
const categories = [...byCategory.entries()]
.map(([name, subtotal]) => ({ name, subtotal: round2(subtotal) }))
.sort((a, b) => categoryRank(a.name) - categoryRank(b.name));
return {
lines,
cutLists,
categories,
totalCost: round2(lines.reduce((sum, l) => sum + l.totalCost, 0)),
partCount: parts.length,
};
}
// ---------- grouping ----------
/** Part types priced as one-off equipment with a spec string. */
const EQUIPMENT_SPEC: Record<string, (p: Record<string, number>) => string> = {
pump: (p) => `${p.gph ?? 400} GPH, ${p.maxHeadFt ?? 8} ft max head`,
reservoir: (p) => {
const w = p.width ?? 2, d = p.depth ?? 1.4, h = p.height ?? 1;
return `${w} × ${d} × ${h} ft, ${cuFtToGallons(w * d * h).toFixed(1)} gal`;
},
tower: (p) => `${p.height ?? 4} ft tall, ${p.sites ?? 6} plant sites`,
tray: (p) => `${p.length ?? 3} × ${p.width ?? 1.2} ft`,
wallPanel: (p) =>
`${p.width ?? 3} × ${p.height ?? 4} ft, ${(p.rows ?? 4) * (p.cols ?? 5)} pockets`,
lattice: (p) => `${p.width ?? 2.5} × ${p.height ?? 3} ft`,
netPot: (p) => `size ${p.size ?? 1}`,
drain: (p) => `${p.capacityGph ?? 250} GPH capacity`,
};
/** Grouping key: identical purchasable items share a key. */
function groupKey(part: PlacedPart): string {
const p = part.params;
if (part.type === 'reducer') {
return `${part.type}:${p.diameterA ?? 1.5}:${p.diameterB ?? 1}`;
}
if (hasParam(p, 'diameter') && isFittingLike(part.type)) {
// Fittings: diameter (and bend angle) define the SKU.
const angle = hasParam(p, 'angle') ? `:${p.angle}` : '';
return `${part.type}:${p.diameter}${angle}`;
}
// Equipment & unknowns: full param signature, ignoring purely cosmetic ones.
const sig = Object.keys(p)
.filter((k) => k !== 'level' && k !== 'open')
.sort()
.map((k) => `${k}=${p[k]}`)
.join(',');
return `${part.type}:${sig}`;
}
function isFittingLike(type: string): boolean {
if (type in EQUIPMENT_SPEC) return false;
return fittingPrice(type, 1) !== null || !(type in PART_DEFS);
}
/** Build one BomLine from a group of identical parts. */
function buildLine(members: PlacedPart[]): BomLine {
const part = members[0];
const { type, params } = part;
const def = (PART_DEFS as Record<string, { label: string; category: string } | undefined>)[type];
const label = def?.label ?? prettifyType(type);
const category = categoryOf(type);
const qty = members.length;
let unitCost: number;
let estimated = false;
let description = label;
let spec: string | undefined;
const equipCost = equipmentPrice(type, params);
const fitDiameter = fittingDiameter(type, params);
const fitCost = fittingPrice(type, fitDiameter);
if (equipCost !== null) {
unitCost = equipCost;
spec = EQUIPMENT_SPEC[type]?.(params);
} else if (fitCost !== null && type in PART_DEFS) {
unitCost = fitCost;
if (hasParam(params, 'diameter')) description = `${formatDiameter(params.diameter)} ${label}`;
if (type === 'reducer') {
description = `${formatDiameter(params.diameterA ?? 1.5)} × ${formatDiameter(params.diameterB ?? 1)} ${label}`;
}
if (hasParam(params, 'angle') && params.angle !== 90) description += ` (${params.angle}°)`;
} else if (type in PART_DEFS && hasParam(params, 'diameter')) {
// Known catalog type we have no price row for, but it looks like a fitting.
unitCost = fittingPrice('generic', params.diameter) ?? UNKNOWN_PART_PRICE;
estimated = true;
description = `${formatDiameter(params.diameter)} ${label}`;
} else {
// Unknown / unpriced type: fallback price, clearly marked estimated.
unitCost = UNKNOWN_PART_PRICE;
estimated = true;
const sig = Object.entries(params)
.map(([k, v]) => `${k} ${v}`)
.join(', ');
spec = sig || undefined;
}
return {
key: groupKey(part),
partType: type,
category,
description,
spec,
qty,
unit: 'each',
unitCost: round2(unitCost),
totalCost: round2(unitCost * qty),
estimated,
partIds: members.map((m) => m.id),
};
}
function fittingDiameter(type: string, params: Record<string, number>): number {
if (type === 'reducer') return Math.max(params.diameterA ?? 1.5, params.diameterB ?? 1);
return params.diameter ?? 1;
}
// ---------- cut list ----------
/**
* Build the cut plan for one diameter: every pipe length (ft → in) becomes a
* cut; runs longer than a stick are split into full sticks plus a remainder
* (each split adds a coupling/splice). Remaining cuts are bin-packed into
* 120-in sticks with first-fit decreasing.
*/
function buildCutList(diameterIn: number, pipes: PlacedPart[]): CutList {
const cuts: number[] = [];
let spliceCount = 0;
for (const pipe of pipes) {
let remaining = round2((pipe.params.length ?? 2) * 12);
if (remaining <= 0) continue;
const pieces: number[] = [];
while (remaining > STICK_LENGTH_IN) {
pieces.push(STICK_LENGTH_IN);
remaining = round2(remaining - STICK_LENGTH_IN);
}
if (remaining > 0) pieces.push(remaining);
spliceCount += pieces.length - 1;
cuts.push(...pieces);
}
const sticks = firstFitDecreasing(cuts, STICK_LENGTH_IN);
const totalCutIn = round2(cuts.reduce((s, c) => s + c, 0));
return {
diameterIn,
stickLengthIn: STICK_LENGTH_IN,
cuts: [...cuts].sort((a, b) => b - a),
sticks,
sticksToBuy: sticks.length,
totalCutIn,
totalLeftoverIn: round2(sticks.reduce((s, st) => s + st.leftoverIn, 0)),
spliceCount,
};
}
/** Classic first-fit-decreasing bin packing into fixed-capacity sticks. */
function firstFitDecreasing(cuts: number[], capacity: number): CutStick[] {
const sorted = [...cuts].sort((a, b) => b - a);
const sticks: CutStick[] = [];
for (const cut of sorted) {
let placed = false;
for (const stick of sticks) {
// Tiny epsilon so float dust never overflows a stick.
if (stick.usedIn + cut <= capacity + 1e-9) {
stick.cuts.push(cut);
stick.usedIn = round2(stick.usedIn + cut);
placed = true;
break;
}
}
if (!placed) {
sticks.push({ index: sticks.length + 1, cuts: [cut], usedIn: cut, leftoverIn: 0 });
}
}
for (const stick of sticks) stick.leftoverIn = round2(capacity - stick.usedIn);
return sticks;
}
// ---------- formatting & ordering helpers ----------
const CATEGORY_ORDER = ['Plumbing', 'Flow Control', 'Growing', 'Structure', 'Other'];
function categoryRank(name: string): number {
const i = CATEGORY_ORDER.indexOf(name);
return i === -1 ? CATEGORY_ORDER.length : i;
}
/** Runtime category lookup — unknown types land in 'Other'. */
function categoryOf(type: string): string {
const def = (PART_DEFS as Record<string, { category?: string } | undefined>)[type];
return def?.category ?? 'Other';
}
function sortLines(lines: BomLine[]) {
lines.sort(
(a, b) =>
categoryRank(a.category) - categoryRank(b.category) ||
a.description.localeCompare(b.description),
);
}
/** 'growTent' → 'Grow Tent'. */
function prettifyType(type: string): string {
return type
.replace(/([a-z])([A-Z])/g, '$1 $2')
.replace(/^./, (c) => c.toUpperCase());
}
const DIAMETER_FRACTIONS: Record<string, string> = {
'0.25': '1/4', '0.5': '1/2', '0.75': '3/4', '1.25': '1-1/4',
'1.5': '1-1/2', '1.75': '1-3/4', '2.5': '2-1/2', '3.5': '3-1/2',
};
/** 0.75 → '3/4 in', 1.5 → '1-1/2 in', 2 → '2 in'. */
export function formatDiameter(diameterIn: number): string {
if (Number.isInteger(diameterIn)) return `${diameterIn} in`;
return `${DIAMETER_FRACTIONS[String(diameterIn)] ?? String(diameterIn)} in`;
}
/** Inches → feet+inches display: 46.5 → 3' 10.5", 24 → 2', 8 → 8". */
export function formatInches(inches: number): string {
const ft = Math.floor(inches / 12);
const rest = round2(inches - ft * 12);
if (ft === 0) return `${trimNum(rest)}"`;
if (rest === 0) return `${ft}'`;
return `${ft}' ${trimNum(rest)}"`;
}
/** USD display with cents. */
export function formatMoney(usd: number): string {
return `$${usd.toFixed(2)}`;
}
const trimNum = (n: number) => String(round2(n));
function hasParam(params: Record<string, number>, key: string): boolean {
return typeof params[key] === 'number';
}
function pushMap<K, V>(map: Map<K, V[]>, key: K, value: V) {
const arr = map.get(key);
if (arr) arr.push(value);
else map.set(key, [value]);
}

136
src/bom/csv.ts Normal file
View File

@@ -0,0 +1,136 @@
import { formatDiameter, formatInches, formatMoney } from './computeBom';
import type { Bom } from './types';
/**
* Exporters: CSV (sectioned spreadsheet), plain-text shopping list, and a
* blob download helper following the pattern in utils/serializer.ts.
*/
/** Escape one CSV cell (quotes cells containing commas/quotes/newlines). */
function cell(value: string | number): string {
const s = String(value);
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
}
const row = (...cells: (string | number)[]) => cells.map(cell).join(',');
/**
* Serialize a Bom to CSV with three sections: LINE ITEMS, CUT LIST, TOTALS.
* Sections are separated by blank lines and introduced by a single-cell
* heading row so the file reads cleanly in any spreadsheet app.
*/
export function bomToCsv(bom: Bom): string {
const out: string[] = [];
out.push(row('LINE ITEMS'));
out.push(row('Category', 'Item', 'Spec', 'Qty', 'Unit', 'Unit Cost', 'Total', 'Pricing'));
for (const line of bom.lines) {
out.push(
row(
line.category,
line.description,
line.spec ?? '',
line.qty,
line.unit,
line.unitCost.toFixed(2),
line.totalCost.toFixed(2),
line.estimated ? 'est.' : 'listed',
),
);
}
if (bom.cutLists.length > 0) {
out.push('');
out.push(row('CUT LIST'));
out.push(row('Diameter', 'Stick', 'Cuts (in)', 'Used (in)', 'Leftover (in)'));
for (const list of bom.cutLists) {
for (const stick of list.sticks) {
out.push(
row(
formatDiameter(list.diameterIn),
`Stick ${stick.index}`,
stick.cuts.join(' | '),
stick.usedIn,
stick.leftoverIn,
),
);
}
}
}
out.push('');
out.push(row('TOTALS'));
for (const cat of bom.categories) {
out.push(row(cat.name, cat.subtotal.toFixed(2)));
}
out.push(row('Grand total', bom.totalCost.toFixed(2)));
out.push(row('Part count', bom.partCount));
return out.join('\n');
}
/** Printable plain-text shopping list (monospace-friendly). */
export function bomToText(bom: Bom): string {
const out: string[] = [];
const hr = '─'.repeat(58);
out.push('HYDRO BUILDER — SHOPPING LIST');
out.push(`${bom.partCount} parts · estimated total ${formatMoney(bom.totalCost)}`);
out.push(hr);
let currentCategory = '';
for (const line of bom.lines) {
if (line.category !== currentCategory) {
currentCategory = line.category;
out.push('');
out.push(`${currentCategory.toUpperCase()}`);
}
const name = line.qty > 1 || line.unit === 'stick'
? `${line.description} ×${line.qty}`
: line.description;
const price = `${formatMoney(line.totalCost)}${line.estimated ? ' est.' : ''}`;
out.push(` ${name.padEnd(44)} ${price}`);
if (line.spec) out.push(` ${line.spec}`);
}
if (bom.cutLists.length > 0) {
out.push('');
out.push(hr);
out.push('CUT LIST (10 ft sticks)');
for (const list of bom.cutLists) {
out.push('');
out.push(
`${formatDiameter(list.diameterIn)} PVC — buy ${list.sticksToBuy} stick${list.sticksToBuy === 1 ? '' : 's'}, ` +
`${formatInches(list.totalCutIn)} of cuts, ${formatInches(list.totalLeftoverIn)} scrap`,
);
for (const stick of list.sticks) {
const cuts = stick.cuts.map((c) => formatInches(c)).join(', ');
out.push(` Stick ${stick.index}: ${cuts}${formatInches(stick.leftoverIn)} left`);
}
if (list.spliceCount > 0) {
out.push(` (${list.spliceCount} coupling${list.spliceCount === 1 ? '' : 's'} needed for runs over 10 ft)`);
}
}
}
out.push('');
out.push(hr);
for (const cat of bom.categories) {
out.push(`${cat.name.padEnd(46)} ${formatMoney(cat.subtotal)}`);
}
out.push(`${'TOTAL (estimated)'.padEnd(46)} ${formatMoney(bom.totalCost)}`);
return out.join('\n');
}
/** Trigger a browser download of the BOM as a CSV file. */
export function downloadBomCsv(bom: Bom, projectName: string) {
const blob = new Blob([bomToCsv(bom)], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const safe = projectName.replace(/[^a-z0-9-_ ]/gi, '').trim() || 'hydro-system';
a.download = `${safe}-bom.csv`;
a.click();
URL.revokeObjectURL(url);
}

20
src/bom/index.ts Normal file
View File

@@ -0,0 +1,20 @@
/**
* Bill of Materials module — public API.
*
* Pure logic:
* computeBom(parts) -> Bom (line items, cut lists, costs)
* bomToCsv(bom) -> sectioned CSV string
* bomToText(bom) -> printable plain-text shopping list
* downloadBomCsv(bom, name) -> trigger a browser .csv download
*
* UI:
* <BomPanel onClose={...} /> -> self-contained drawer reading useBuilder
*
* Formatting helpers (used by the panel, exported for reuse):
* formatDiameter, formatInches, formatMoney
*/
export { computeBom, formatDiameter, formatInches, formatMoney } from './computeBom';
export { bomToCsv, bomToText, downloadBomCsv } from './csv';
export { downloadBomPdf } from './pdf';
export { BomPanel } from './BomPanel';
export type { Bom, BomLine, CutList, CutStick, CategorySubtotal } from './types';

215
src/bom/pdf.ts Normal file
View File

@@ -0,0 +1,215 @@
import { jsPDF } from 'jspdf';
import autoTable from 'jspdf-autotable';
import { formatDiameter, formatInches, formatMoney } from './computeBom';
import type { Bom } from './types';
/**
* Generates and downloads a professional PDF report of the Bill of Materials.
*/
export function downloadBomPdf(bom: Bom, projectName: string) {
const doc = new jsPDF({
orientation: 'portrait',
unit: 'mm',
format: 'a4',
});
const pageWidth = doc.internal.pageSize.getWidth();
const margin = 15;
// --- 1. Header Section ---
doc.setFillColor(15, 23, 42); // slate-900 bg
doc.rect(0, 0, pageWidth, 40, 'F');
doc.setTextColor(255, 255, 255);
doc.setFont('helvetica', 'bold');
doc.setFontSize(18);
doc.text('HYDRO BUILDER', margin, 18);
doc.setFont('helvetica', 'normal');
doc.setFontSize(10);
doc.setTextColor(148, 163, 184); // slate-400
doc.text('BILL OF MATERIALS', margin, 24);
// Project details on the right side of header
doc.setTextColor(255, 255, 255);
doc.setFont('helvetica', 'bold');
doc.setFontSize(11);
const nameText = projectName.length > 30 ? projectName.substring(0, 27) + '...' : projectName;
doc.text(nameText, pageWidth - margin, 18, { align: 'right' });
doc.setFont('helvetica', 'normal');
doc.setFontSize(9);
doc.setTextColor(148, 163, 184);
doc.text(`Date: ${new Date().toLocaleDateString()}`, pageWidth - margin, 24, { align: 'right' });
let currentY = 50;
// --- 2. Summary Stats Card ---
doc.setFillColor(248, 250, 252); // slate-50
doc.setDrawColor(226, 232, 240); // slate-200
doc.rect(margin, currentY, pageWidth - 2 * margin, 22, 'FD');
doc.setFont('helvetica', 'normal');
doc.setFontSize(8);
doc.setTextColor(100, 116, 139); // slate-500
doc.text('TOTAL ESTIMATED COST', margin + 8, currentY + 7);
doc.text('PART COUNT', margin + 65, currentY + 7);
doc.text('LINE ITEMS', margin + 115, currentY + 7);
doc.setFont('helvetica', 'bold');
doc.setFontSize(12);
doc.setTextColor(14, 116, 144); // cyan-700
doc.text(formatMoney(bom.totalCost), margin + 8, currentY + 15);
doc.setTextColor(15, 23, 42); // slate-900
doc.text(`${bom.partCount} parts`, margin + 65, currentY + 15);
doc.text(`${bom.lines.length} items`, margin + 115, currentY + 15);
if (bom.cutLists.length > 0) {
const totalSticks = bom.cutLists.reduce((s, c) => s + c.sticksToBuy, 0);
doc.setFont('helvetica', 'normal');
doc.setFontSize(8);
doc.setTextColor(100, 116, 139);
doc.text('PVC STICKS (10 FT)', margin + 150, currentY + 7);
doc.setFont('helvetica', 'bold');
doc.setFontSize(12);
doc.setTextColor(15, 23, 42);
doc.text(`${totalSticks} sticks`, margin + 150, currentY + 15);
}
currentY += 32;
// --- 3. Line Items Table ---
doc.setFont('helvetica', 'bold');
doc.setFontSize(11);
doc.setTextColor(15, 23, 42);
doc.text('Line Items', margin, currentY);
currentY += 4;
const lineColumns = ['Category', 'Description', 'Spec', 'Qty', 'Unit', 'Unit Cost', 'Total'];
const lineRows = bom.lines.map((l) => [
l.category,
l.description,
l.spec ?? '',
l.qty,
l.unit,
formatMoney(l.unitCost),
formatMoney(l.totalCost) + (l.estimated ? ' (est.)' : ''),
]);
autoTable(doc, {
startY: currentY,
head: [lineColumns],
body: lineRows,
margin: { left: margin, right: margin },
theme: 'grid',
styles: { fontSize: 8, cellPadding: 2, font: 'helvetica' },
headStyles: { fillColor: [39, 39, 42], textColor: [255, 255, 255], fontStyle: 'bold' },
columnStyles: {
0: { cellWidth: 25 },
1: { cellWidth: 50 },
2: { cellWidth: 40 },
3: { cellWidth: 12, halign: 'center' },
4: { cellWidth: 15, halign: 'center' },
5: { cellWidth: 18, halign: 'right' },
6: { cellWidth: 20, halign: 'right' },
},
});
currentY = (doc as any).lastAutoTable.finalY + 10;
// --- 4. Cut Lists (Bin Packing) Section ---
if (bom.cutLists.length > 0) {
// Check if we need a page break
if (currentY > doc.internal.pageSize.getHeight() - 40) {
doc.addPage();
currentY = 20;
}
doc.setFont('helvetica', 'bold');
doc.setFontSize(11);
doc.setTextColor(15, 23, 42);
doc.text('PVC Cut Lists (10 ft / 120 in sticks)', margin, currentY);
currentY += 4;
const cutColumns = ['Diameter', 'Stick', 'Cuts (in)', 'Used', 'Leftover'];
const cutRows: any[] = [];
for (const list of bom.cutLists) {
for (const stick of list.sticks) {
const cutsStr = stick.cuts.map((c) => formatInches(c)).join(', ');
cutRows.push([
formatDiameter(list.diameterIn),
`Stick ${stick.index}`,
cutsStr,
formatInches(stick.usedIn),
formatInches(stick.leftoverIn),
]);
}
}
autoTable(doc, {
startY: currentY,
head: [cutColumns],
body: cutRows,
margin: { left: margin, right: margin },
theme: 'grid',
styles: { fontSize: 8, cellPadding: 2, font: 'helvetica' },
headStyles: { fillColor: [71, 85, 105], textColor: [255, 255, 255], fontStyle: 'bold' },
columnStyles: {
0: { cellWidth: 25 },
1: { cellWidth: 25 },
2: { cellWidth: 90 },
3: { cellWidth: 20, halign: 'right' },
4: { cellWidth: 20, halign: 'right' },
},
});
currentY = (doc as any).lastAutoTable.finalY + 10;
}
// --- 5. Cost Summary & Totals ---
if (currentY > doc.internal.pageSize.getHeight() - 50) {
doc.addPage();
currentY = 20;
}
doc.setFont('helvetica', 'bold');
doc.setFontSize(11);
doc.setTextColor(15, 23, 42);
doc.text('Category Subtotals', margin, currentY);
currentY += 4;
const totalColumns = ['Category', 'Subtotal'];
const totalRows = bom.categories.map((c) => [c.name, formatMoney(c.subtotal)]);
totalRows.push(['Grand Total (Estimated)', formatMoney(bom.totalCost)]);
autoTable(doc, {
startY: currentY,
head: [totalColumns],
body: totalRows,
margin: { left: margin, right: pageWidth - 100 }, // right aligned summary table
theme: 'grid',
styles: { fontSize: 8, cellPadding: 2.5, font: 'helvetica' },
headStyles: { fillColor: [15, 23, 42], textColor: [255, 255, 255], fontStyle: 'bold' },
columnStyles: {
0: { cellWidth: 50 },
1: { cellWidth: 35, halign: 'right', fontStyle: 'bold' },
},
didParseCell: (data) => {
// Highlight Grand Total row
if (data.row.index === totalRows.length - 1) {
data.cell.styles.fillColor = [241, 245, 249];
data.cell.styles.fontStyle = 'bold';
if (data.column.index === 1) {
data.cell.styles.textColor = [14, 116, 144];
}
}
},
});
// --- 6. Save & Download ---
const safeName = projectName.replace(/[^a-z0-9-_ ]/gi, '').trim() || 'hydro-system';
doc.save(`${safeName}-bom.pdf`);
}

126
src/bom/prices.ts Normal file
View File

@@ -0,0 +1,126 @@
/**
* Hardware-store price book for the BOM cost estimate.
*
* Numbers are ballpark US retail (big-box store, schedule-40 PVC) and exist
* to give a useful estimate, not a quote. Every price helper degrades
* gracefully: unknown part types get `UNKNOWN_PART_PRICE` and are flagged
* "est." in the output.
*/
/** Standard purchasable PVC stick length (inches) = 10 ft. */
export const STICK_LENGTH_IN = 120;
/** Fallback price for part types this module doesn't know about (USD). */
export const UNKNOWN_PART_PRICE = 5;
/** Schedule-40 PVC pipe, USD per linear foot, keyed by nominal diameter (in). */
const PVC_PER_FOOT: [maxDiameterIn: number, perFoot: number][] = [
[0.5, 0.45],
[0.75, 0.55],
[1, 0.75],
[1.25, 0.95],
[1.5, 1.1],
[2, 1.55],
[3, 2.9],
[4, 3.8],
];
/** USD per foot of PVC pipe at the given diameter (inches). */
export function pvcPerFoot(diameterIn: number): number {
for (const [max, price] of PVC_PER_FOOT) {
if (diameterIn <= max) return price;
}
// Beyond 4 in: extrapolate roughly $1/ft per extra inch.
return 3.8 + (diameterIn - 4) * 1;
}
/** Price of one full 10-ft stick at the given diameter (USD). */
export function pvcStickPrice(diameterIn: number): number {
return round2(pvcPerFoot(diameterIn) * (STICK_LENGTH_IN / 12));
}
/**
* Fitting prices by diameter tier: small (≤1 in), medium (≤2 in), large (>2 in).
* Types not listed here fall back to the generic-fitting row.
*/
const FITTING_PRICES: Record<string, [small: number, medium: number, large: number]> = {
elbow: [0.6, 1.4, 3.6],
tee: [0.85, 1.9, 4.6],
cross: [1.6, 3.2, 7.5],
yJoint: [1.4, 2.8, 6.5],
wye: [1.4, 2.8, 6.5],
reducer: [0.9, 1.8, 4.2],
coupling: [0.45, 0.95, 2.4],
valve: [6, 11, 24],
emitter: [1.25, 1.25, 1.25],
reverseOsmosis: [15, 30, 65],
generic: [1, 2, 5],
};
/** Price for a fitting of `type` at `diameterIn` inches; null if not a known fitting. */
export function fittingPrice(type: string, diameterIn: number): number | null {
const row = FITTING_PRICES[type];
if (!row) return null;
const tier = diameterIn <= 1 ? 0 : diameterIn <= 2 ? 1 : 2;
return row[tier];
}
/** Submersible/inline pump price by rated flow (GPH). */
export function pumpPrice(gph: number): number {
if (gph <= 200) return 18;
if (gph <= 400) return 28;
if (gph <= 800) return 45;
if (gph <= 1200) return 70;
return 110;
}
/** Reservoir/tank price by capacity (US gallons). */
export function reservoirPrice(gallons: number): number {
if (gallons <= 10) return 15;
if (gallons <= 20) return 25;
if (gallons <= 40) return 45;
if (gallons <= 75) return 80;
return 130;
}
/** Cubic feet → US gallons. */
export const cuFtToGallons = (cuFt: number) => cuFt * 7.48052;
/**
* Price an individual equipment/grow part from its params. Returns null for
* types handled elsewhere (pipes, fittings) or unknown to the price book.
*/
export function equipmentPrice(type: string, params: Record<string, number>): number | null {
switch (type) {
case 'pump':
return pumpPrice(params.gph ?? 400);
case 'reservoir': {
const gal = cuFtToGallons((params.width ?? 2) * (params.depth ?? 1.4) * (params.height ?? 1));
return reservoirPrice(gal);
}
case 'tower':
// Tower shell + per-foot extrusion + per-site cups.
return round2(18 + (params.height ?? 4) * 5 + (params.sites ?? 6) * 1);
case 'tray':
return round2(12 + (params.length ?? 3) * (params.width ?? 1.2) * 6);
case 'wallPanel':
return round2(20 + (params.rows ?? 4) * (params.cols ?? 5) * 3);
case 'lattice':
return round2(10 + (params.width ?? 2.5) * (params.height ?? 3) * 4);
case 'netPot':
return round2(0.75 * (params.size ?? 1));
case 'drain':
return (params.capacityGph ?? 250) <= 250 ? 8 : 14;
case 'growTent':
case 'tent':
return round2(90 + (params.width ?? 4) * (params.depth ?? 4) * (params.height ?? 6.5) * 1.2);
case 'light':
case 'growLight':
return round2(40 + (params.watts ?? 100) * 0.6);
default:
return null;
}
}
/** Round to cents. */
export const round2 = (n: number) => Math.round(n * 100) / 100;

170
src/bom/selftest.ts Normal file
View File

@@ -0,0 +1,170 @@
/**
* BOM self-test — run with: npx tsx src/bom/selftest.ts
*
* Exercises computeBom, the cut-list bin packing, and the CSV/text exporters
* against a synthetic parts list (including an unknown future part type).
* Prints PASS/FAIL per check and exits non-zero on any failure.
*/
import type { PlacedPart } from '../types';
import { computeBom } from './computeBom';
import { bomToCsv, bomToText } from './csv';
import { STICK_LENGTH_IN, UNKNOWN_PART_PRICE } from './prices';
let failures = 0;
function check(name: string, ok: boolean, detail = '') {
console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${!ok && detail ? `${detail}` : ''}`);
if (!ok) failures++;
}
let nextId = 0;
function part(type: string, params: Record<string, number>): PlacedPart {
return {
id: `t${nextId++}`,
type: type as PlacedPart['type'],
position: [0, 0, 0],
rotation: [0, 0, 0],
params,
};
}
// ---------- synthetic design ----------
const parts: PlacedPart[] = [
// 1-in pipes: 46.5" + 24" + 18" + 60" + 96" = 244.5" of cuts.
part('pipe', { length: 46.5 / 12, diameter: 1 }),
part('pipe', { length: 2, diameter: 1 }),
part('pipe', { length: 1.5, diameter: 1 }),
part('pipe', { length: 5, diameter: 1 }),
part('pipe', { length: 8, diameter: 1 }),
// A 12-ft run of 2-in pipe: must split into 120" + 24" with one splice.
part('pipe', { length: 12, diameter: 2 }),
// Fittings: six 1-in elbows (one at 45°), two 1-in tees, one 2-in tee.
...Array.from({ length: 5 }, () => part('elbow', { angle: 90, diameter: 1 })),
part('elbow', { angle: 45, diameter: 1 }),
part('tee', { diameter: 1 }),
part('tee', { diameter: 1 }),
part('tee', { diameter: 2 }),
// Equipment.
part('valve', { diameter: 1, open: 100 }),
part('pump', { gph: 400, maxHeadFt: 8 }),
part('reservoir', { width: 2, depth: 1.4, height: 1, level: 70 }),
part('tower', { height: 4, sites: 6 }),
part('netPot', { size: 1 }),
part('netPot', { size: 1 }),
part('emitter', {}),
// Unknown future part type — must not crash, must get fallback price.
part('flummoxValve', { weirdness: 11 }),
];
const bom = computeBom(parts);
// ---------- counts & grouping ----------
check('partCount matches input', bom.partCount === parts.length,
`got ${bom.partCount}, want ${parts.length}`);
const elbow90 = bom.lines.find((l) => l.partType === 'elbow' && l.key.endsWith(':90'));
const elbow45 = bom.lines.find((l) => l.partType === 'elbow' && l.key.endsWith(':45'));
check('90° elbows grouped (×5)', elbow90?.qty === 5, `got ${elbow90?.qty}`);
check('45° elbow split into its own line (×1)', elbow45?.qty === 1, `got ${elbow45?.qty}`);
const tee1 = bom.lines.find((l) => l.partType === 'tee' && l.key === 'tee:1');
const tee2 = bom.lines.find((l) => l.partType === 'tee' && l.key === 'tee:2');
check('1-in tees grouped (×2)', tee1?.qty === 2, `got ${tee1?.qty}`);
check('2-in tee separate (×1)', tee2?.qty === 1, `got ${tee2?.qty}`);
const pots = bom.lines.find((l) => l.partType === 'netPot');
check('identical net pots grouped (×2)', pots?.qty === 2, `got ${pots?.qty}`);
const pump = bom.lines.find((l) => l.partType === 'pump');
check('pump line carries spec', !!pump?.spec && pump.spec.includes('400 GPH'),
`spec: ${pump?.spec}`);
const reservoir = bom.lines.find((l) => l.partType === 'reservoir');
check('reservoir spec includes gallons', !!reservoir?.spec && /gal/.test(reservoir.spec),
`spec: ${reservoir?.spec}`);
// ---------- cut lists ----------
const list1 = bom.cutLists.find((c) => c.diameterIn === 1);
const list2 = bom.cutLists.find((c) => c.diameterIn === 2);
check('cut list per diameter (2 lists)', bom.cutLists.length === 2,
`got ${bom.cutLists.length}`);
check('1-in cuts total 244.5 in', list1?.totalCutIn === 244.5, `got ${list1?.totalCutIn}`);
const allCutsCovered =
!!list1 &&
list1.sticks.flatMap((s) => s.cuts).sort((a, b) => a - b).join() ===
[...list1.cuts].sort((a, b) => a - b).join();
check('every 1-in cut appears in exactly one stick', allCutsCovered);
const noOverflow = bom.cutLists.every((cl) =>
cl.sticks.every((s) => s.usedIn <= STICK_LENGTH_IN + 1e-9 &&
Math.abs(s.usedIn + s.leftoverIn - STICK_LENGTH_IN) < 0.02),
);
check('no stick exceeds 120 in; used+leftover = 120', noOverflow);
// 244.5" of cuts can't fit in 2 sticks (240"); FFD should land on exactly 3.
check('1-in plan needs 3 sticks', list1?.sticksToBuy === 3, `got ${list1?.sticksToBuy}`);
check('12-ft 2-in run split with 1 splice', list2?.spliceCount === 1 && list2.sticksToBuy === 2,
`splices ${list2?.spliceCount}, sticks ${list2?.sticksToBuy}`);
const couplingLine = bom.lines.find((l) => l.partType === 'coupling');
check('splice adds a coupling line item', couplingLine?.qty === 1, `got ${couplingLine?.qty}`);
// ---------- unknown part type ----------
const unknown = bom.lines.find((l) => l.partType === 'flummoxValve');
check('unknown type lands in output', !!unknown);
check('unknown type gets fallback price, marked est.',
unknown?.unitCost === UNKNOWN_PART_PRICE && unknown.estimated === true,
`cost ${unknown?.unitCost}, estimated ${unknown?.estimated}`);
check('unknown type categorized as Other', unknown?.category === 'Other',
`got ${unknown?.category}`);
// ---------- totals ----------
const lineSum = Math.round(bom.lines.reduce((s, l) => s + l.totalCost, 0) * 100) / 100;
check('grand total equals sum of lines', Math.abs(bom.totalCost - lineSum) < 0.005,
`total ${bom.totalCost}, sum ${lineSum}`);
const catSum = Math.round(bom.categories.reduce((s, c) => s + c.subtotal, 0) * 100) / 100;
check('category subtotals add to grand total', Math.abs(bom.totalCost - catSum) < 0.01,
`total ${bom.totalCost}, cats ${catSum}`);
// ---------- CSV / text sanity ----------
const csv = bomToCsv(bom);
const csvRows = csv.split('\n');
const stickRows = bom.cutLists.reduce((s, c) => s + c.sticks.length, 0);
// 3 section headers + 2 column-header rows + lines + sticks + cats + total + count + 2 blanks.
const expectedRows = 3 + 2 + bom.lines.length + stickRows + bom.categories.length + 2 + 2;
check('CSV row count matches structure', csvRows.length === expectedRows,
`got ${csvRows.length}, want ${expectedRows}`);
check('CSV contains all three sections',
csv.includes('LINE ITEMS') && csv.includes('CUT LIST') && csv.includes('TOTALS'));
const text = bomToText(bom);
check('text export mentions stick cut sequence', /Stick 1: .*left/.test(text));
check('text export includes grand total', text.includes('TOTAL (estimated)'));
// ---------- empty input ----------
const empty = computeBom([]);
check('empty input yields empty BOM',
empty.partCount === 0 && empty.lines.length === 0 && empty.totalCost === 0);
// ---------- result ----------
console.log('');
if (failures === 0) {
console.log('ALL CHECKS PASSED');
} else {
console.log(`${failures} CHECK(S) FAILED`);
// No @types/node in this project — reach process through globalThis.
const proc = (globalThis as { process?: { exitCode?: number } }).process;
if (proc) proc.exitCode = 1;
}

88
src/bom/types.ts Normal file
View File

@@ -0,0 +1,88 @@
/**
* Bill-of-Materials domain types.
*
* Units follow the app convention: lengths in the scene are feet, pipe
* diameters are inches. Cut-list math is done in INCHES (a standard PVC
* stick = 120 in = 10 ft). All money values are USD, rounded to cents.
*/
/** One shopping-list line (a group of identical purchasable items). */
export interface BomLine {
/** Stable grouping key: part type + SKU-relevant params. */
key: string;
/** Raw part type ('pipe', 'elbow', ... or an unknown future type). */
partType: string;
/** Catalog category ('Plumbing', 'Growing', ...) or 'Other' for unknown types. */
category: string;
/** Human-readable item name, e.g. "1 in PVC Elbow". */
description: string;
/** Key specs, e.g. "400 GPH, 8 ft max head". */
spec?: string;
/** How many to buy (for pipe: number of 10-ft sticks). */
qty: number;
/** Purchase unit. */
unit: 'each' | 'stick';
/** Price per unit (USD). */
unitCost: number;
/** qty * unitCost (USD). */
totalCost: number;
/** True when the price is a blind fallback for an unknown part type. */
estimated: boolean;
/** Ids of the placed parts folded into this line. */
partIds: string[];
}
/** One physical 10-ft stick and the cuts assigned to it. */
export interface CutStick {
/** 1-based stick number within its cut list. */
index: number;
/** Cut lengths in inches, in cutting order (longest first). */
cuts: number[];
/** Total inches consumed. */
usedIn: number;
/** Scrap remaining on this stick (inches). */
leftoverIn: number;
}
/** Cut plan for all pipes of one diameter. */
export interface CutList {
/** Pipe diameter (inches). */
diameterIn: number;
/** Stock stick length (inches) — 120 for standard 10-ft sticks. */
stickLengthIn: number;
/** Every cut to make (inches), after splitting oversize runs. */
cuts: number[];
/** Bin-packed sticks (first-fit decreasing). */
sticks: CutStick[];
/** Number of sticks to buy (= sticks.length). */
sticksToBuy: number;
/** Sum of all cuts (inches). */
totalCutIn: number;
/** Total scrap across all sticks (inches). */
totalLeftoverIn: number;
/**
* Number of couplings implied by runs longer than one stick (a 12-ft pipe
* is supplied as a full stick + a 2-ft piece joined by a coupling).
*/
spliceCount: number;
}
/** Per-category cost rollup. */
export interface CategorySubtotal {
name: string;
subtotal: number;
}
/** Complete bill of materials for a design. */
export interface Bom {
/** Shopping-list lines, sorted by category then description. */
lines: BomLine[];
/** One cut plan per pipe diameter present in the design. */
cutLists: CutList[];
/** Cost subtotal per category, in display order. */
categories: CategorySubtotal[];
/** Grand total (USD). */
totalCost: number;
/** Number of placed parts that went into this BOM. */
partCount: number;
}

View File

@@ -0,0 +1,566 @@
/**
* Blueprint generator — renders an orthographic projection of the placed
* parts as a dimensioned, print-friendly SVG sheet (black-on-white with
* subtle part-color fills), including a 1-ft grid, overall dimension lines,
* pipe length callouts, elevation callouts, numbered part keys, and a
* legend table.
*/
import type { PlacedPart, Vec3 } from '../types';
import {
assignKeys,
escapeXml,
fmtFtIn,
mergedParams,
partColor,
partDef,
partLabel,
partSizeText,
rotateVec,
safeConnectors,
worldAABB,
type Box3,
} from './common';
export type BlueprintView = 'top' | 'front' | 'side';
export interface BlueprintOptions {
/** Sheet width in px (default 1100). */
width?: number;
/** Title shown in the sheet header (default "Hydro Builder"). */
title?: string;
}
export interface LegendRow {
partId: string;
ref: string;
label: string;
type: string;
size: string;
}
export interface BlueprintSvg {
svg: string;
width: number;
height: number;
view: BlueprintView;
/** Drawing scale actually used. */
pxPerFt: number;
/** Projected content extents in feet (width × height of the drawing). */
extents: { w: number; h: number };
legend: LegendRow[];
}
// Sheet palette — black-on-white for print, blue dimensions, light grid.
const INK = '#0f172a';
const DIM = '#1d4ed8';
const GRID = '#e2e8f0';
const GRID5 = '#cbd5e1';
const FRAME = '#64748b';
const TEXT = '#475569';
const MONO = 'ui-monospace, SFMono-Regular, Menlo, monospace';
const VIEW_NAME: Record<BlueprintView, string> = {
top: 'TOP VIEW (PLAN)',
front: 'FRONT ELEVATION',
side: 'SIDE ELEVATION',
};
/** Project a world point/vector to sheet coords (u right, v up), in feet. */
function proj(view: BlueprintView, p: Vec3): [number, number] {
if (view === 'top') return [p[0], -p[2]];
if (view === 'front') return [p[0], p[1]];
return [p[2], p[1]];
}
interface Rect2 {
u0: number;
u1: number;
v0: number;
v1: number;
}
function projBox(view: BlueprintView, b: Box3): Rect2 {
const [ua, va] = proj(view, b.min);
const [ub, vb] = proj(view, b.max);
return {
u0: Math.min(ua, ub),
u1: Math.max(ua, ub),
v0: Math.min(va, vb),
v1: Math.max(va, vb),
};
}
const isTentType = (t: string) => t.toLowerCase().includes('tent');
const VESSEL_TYPES = new Set(['reservoir', 'tray', 'wallPanel', 'lattice', 'tower']);
const n2 = (n: number) => Math.round(n * 100) / 100;
export function generateBlueprint(
parts: PlacedPart[],
view: BlueprintView,
opts: BlueprintOptions = {},
): BlueprintSvg {
const W = opts.width ?? 1100;
const title = opts.title ?? 'Hydro Builder';
const keys = assignKeys(parts);
const ref = (id: string) => keys.get(id)?.ref ?? '?';
// ---------------------------------------------------------- sheet layout
const margin = 24;
const headerH = 54;
const dimL = 64; // left gutter for the vertical dimension
const dimR = 96; // right gutter for elevation callouts
const dimB = 54; // bottom gutter for the horizontal dimension
const x0 = margin + dimL;
const y0 = headerH + 14;
const drawW = W - x0 - margin - dimR;
const drawH = 520;
// ------------------------------------------------- content bounds (feet)
let u0c = Infinity, u1c = -Infinity, v0c = Infinity, v1c = -Infinity;
const boxes = new Map<string, Rect2>();
for (const p of parts) {
const r = projBox(view, worldAABB(p));
boxes.set(p.id, r);
u0c = Math.min(u0c, r.u0);
u1c = Math.max(u1c, r.u1);
v0c = Math.min(v0c, r.v0);
v1c = Math.max(v1c, r.v1);
}
if (!parts.length) {
u0c = 0; u1c = 8; v0c = 0; v1c = 6;
}
// Ground line at 0 is meaningful in elevations — include it.
if (view !== 'top') v0c = Math.min(v0c, 0);
const pad = 0.6;
const bw = u1c - u0c + pad * 2;
const bh = v1c - v0c + pad * 2;
const s = Math.min(drawW / bw, drawH / bh, 120);
const uLeft = u0c - pad - (drawW / s - bw) / 2;
const vTop = v1c + pad + (drawH / s - bh) / 2;
const X = (u: number) => n2(x0 + (u - uLeft) * s);
const Y = (v: number) => n2(y0 + (vTop - v) * s);
const out: string[] = [];
const arrId = `bsarr-${view}`;
// ------------------------------------------------------------ grid+frame
out.push(`<rect x="${x0}" y="${y0}" width="${drawW}" height="${drawH}" fill="#ffffff"/>`);
const uG0 = Math.ceil(uLeft);
const uG1 = Math.floor(uLeft + drawW / s);
for (let u = uG0; u <= uG1; u++) {
out.push(
`<line x1="${X(u)}" y1="${y0}" x2="${X(u)}" y2="${y0 + drawH}" stroke="${u % 5 === 0 ? GRID5 : GRID}" stroke-width="1"/>`,
);
}
const vG1 = Math.floor(vTop);
const vG0 = Math.ceil(vTop - drawH / s);
for (let v = vG0; v <= vG1; v++) {
out.push(
`<line x1="${x0}" y1="${Y(v)}" x2="${x0 + drawW}" y2="${Y(v)}" stroke="${v % 5 === 0 ? GRID5 : GRID}" stroke-width="1"/>`,
);
}
// Floor line in elevations.
if (view !== 'top' && 0 >= vTop - drawH / s && 0 <= vTop) {
out.push(
`<line x1="${x0}" y1="${Y(0)}" x2="${x0 + drawW}" y2="${Y(0)}" stroke="${FRAME}" stroke-width="1.5"/>`,
`<text x="${x0 + 4}" y="${Y(0) + 12}" font-size="8" font-family="${MONO}" fill="${TEXT}">FLOOR EL 0'-0"</text>`,
);
}
// -------------------------------------------------------------- glyphs
const zOrder = (p: PlacedPart) =>
VESSEL_TYPES.has(p.type) || isTentType(p.type) ? 0 : p.type === 'pipe' ? 1 : 2;
const drawParts = [...parts].sort((a, b) => zOrder(a) - zOrder(b));
const pipeW = (diaIn: number) => Math.max((diaIn / 12) * s, 3);
/** Double-line stroke pair (outer ink + inner white) for a path `d`. */
const dline = (d: string, w: number) =>
`<path d="${d}" fill="none" stroke="${INK}" stroke-width="${n2(w)}"/>` +
`<path d="${d}" fill="none" stroke="#ffffff" stroke-width="${n2(Math.max(w - 2.4, 1))}"/>`;
function pipeGlyph(part: PlacedPart): string {
const p = mergedParams(part);
const len = p.length ?? 1;
const dir = rotateVec(part.rotation, [1, 0, 0]);
const [du, dv] = proj(view, dir);
const plen = Math.hypot(du, dv) * len;
const [cu, cv] = proj(view, part.position);
const cx = X(cu);
const cy = Y(cv);
const w = pipeW(p.diameter ?? 1);
const color = partColor(part);
if (plen * s < 4) {
// Pipe runs perpendicular to the sheet — draw its end view.
const r = Math.max(w / 2, 3.5);
return (
`<circle cx="${cx}" cy="${cy}" r="${n2(r + 1.5)}" fill="${color}" fill-opacity="0.18" stroke="${INK}" stroke-width="1.2"/>` +
`<circle cx="${cx}" cy="${cy}" r="${n2(Math.max(r - 1.5, 1.2))}" fill="none" stroke="${INK}" stroke-width="0.8"/>`
);
}
const deg = n2((Math.atan2(-dv, du) * 180) / Math.PI);
let textDeg = deg;
if (textDeg > 90 || textDeg < -90) textDeg = n2(textDeg + 180);
const L = n2(plen * s);
let g =
`<g transform="translate(${cx},${cy}) rotate(${deg})">` +
`<rect x="${n2(-L / 2)}" y="${n2(-w / 2)}" width="${L}" height="${n2(w)}" fill="${color}" fill-opacity="0.2" stroke="${INK}" stroke-width="1.2"/>` +
`</g>`;
// Length callout for pipes >= 1 ft that read at this scale.
if (len >= 1 && L >= 34) {
g +=
`<g transform="translate(${cx},${cy}) rotate(${textDeg})">` +
`<text x="0" y="${n2(-w / 2 - 5)}" text-anchor="middle" font-size="10" font-family="${MONO}" fill="${DIM}">${fmtFtIn(len)}</text>` +
`</g>`;
}
return g;
}
function elbowGlyph(part: PlacedPart): string {
const p = mergedParams(part);
const w = Math.max(pipeW(p.diameter ?? 1), 4);
const conns = safeConnectors(part);
const [cu, cv] = proj(view, part.position);
const cx = X(cu);
const cy = Y(cv);
if (conns.length < 2) return `<circle cx="${cx}" cy="${cy}" r="4" fill="none" stroke="${INK}" stroke-width="1.2"/>`;
const [ua, va] = proj(view, conns[0].pos);
const [ub, vb] = proj(view, conns[1].pos);
const ax = X(ua), ay = Y(va), bx = X(ub), by = Y(vb);
if (Math.hypot(bx - ax, by - ay) < 3) {
const r = Math.max(w / 2, 3.5);
return `<circle cx="${cx}" cy="${cy}" r="${n2(r + 1.5)}" fill="none" stroke="${INK}" stroke-width="1.2"/>`;
}
return dline(`M ${ax} ${ay} Q ${cx} ${cy} ${bx} ${by}`, w + 1.5);
}
/** Junction glyph (tee/cross/wye/…): double lines from center to each port. */
function junctionGlyph(part: PlacedPart): string {
const p = mergedParams(part);
const w = Math.max(pipeW(p.diameter ?? 1), 4);
const [cu, cv] = proj(view, part.position);
const cx = X(cu);
const cy = Y(cv);
const legs = safeConnectors(part)
.map((c) => {
const [u, v] = proj(view, c.pos);
return [X(u), Y(v)] as [number, number];
})
.filter(([lx, ly]) => Math.hypot(lx - cx, ly - cy) >= 2);
if (!legs.length)
return `<circle cx="${cx}" cy="${cy}" r="${n2(Math.max(w / 2, 3.5))}" fill="none" stroke="${INK}" stroke-width="1.2"/>`;
const outer = legs
.map(([lx, ly]) => `<line x1="${cx}" y1="${cy}" x2="${lx}" y2="${ly}" stroke="${INK}" stroke-width="${n2(w + 1.5)}"/>`)
.join('');
const inner = legs
.map(([lx, ly]) => `<line x1="${cx}" y1="${cy}" x2="${lx}" y2="${ly}" stroke="#ffffff" stroke-width="${n2(Math.max(w - 1, 1.5))}"/>`)
.join('');
return outer + inner;
}
function pumpGlyph(part: PlacedPart): string {
const [cu, cv] = proj(view, part.position);
const cx = X(cu);
const cy = Y(cv) - (view === 'top' ? 0 : 0.25 * s);
const r = Math.max(0.3 * s, 9);
const outC = safeConnectors(part).find((c) => c.connectorId === 'out');
let ang = 0;
if (outC) {
const [du, dv] = proj(view, outC.dir);
if (Math.hypot(du, dv) > 0.01) ang = Math.atan2(-dv, du);
}
const tri = [0.85, (2 * Math.PI) / 3 + 0.6, -(2 * Math.PI) / 3 - 0.6]
.map((a, i) => {
const rr = i === 0 ? r * 0.78 : r * 0.62;
const aa = i === 0 ? ang : ang + (i === 1 ? 2.5 : -2.5);
return `${n2(cx + Math.cos(aa) * rr)},${n2(cy + Math.sin(aa) * rr)}`;
})
.join(' ');
return (
`<circle cx="${cx}" cy="${n2(cy)}" r="${n2(r)}" fill="${partColor(part)}" fill-opacity="0.15" stroke="${INK}" stroke-width="1.5"/>` +
`<polygon points="${tri}" fill="${INK}"/>`
);
}
function valveGlyph(part: PlacedPart): string {
const [cu, cv] = proj(view, part.position);
const cx = X(cu);
const cy = Y(cv);
const conns = safeConnectors(part);
let deg = 0;
if (conns.length >= 2) {
const [ua, va] = proj(view, conns[0].pos);
const [ub, vb] = proj(view, conns[1].pos);
if (Math.hypot(ub - ua, vb - va) > 0.01) deg = (Math.atan2(-(vb - va), ub - ua) * 180) / Math.PI;
}
const h = Math.max(0.32 * s, 9);
const wg = Math.max(0.22 * s, 6);
return (
`<g transform="translate(${cx},${cy}) rotate(${n2(deg)})">` +
`<path d="M ${n2(-h)} ${n2(-wg)} L 0 0 L ${n2(-h)} ${n2(wg)} Z M ${n2(h)} ${n2(-wg)} L 0 0 L ${n2(h)} ${n2(wg)} Z" fill="${partColor(part)}" fill-opacity="0.3" stroke="${INK}" stroke-width="1.2"/>` +
`</g>`
);
}
function drainGlyph(part: PlacedPart): string {
const [cu, cv] = proj(view, part.position);
const cx = X(cu);
const cy = Y(cv);
const r = Math.max(0.22 * s, 7);
const k = n2(r * 0.65);
return (
`<circle cx="${cx}" cy="${cy}" r="${n2(r)}" fill="none" stroke="${INK}" stroke-width="1.4"/>` +
`<line x1="${n2(cx - k)}" y1="${n2(cy - k)}" x2="${n2(cx + k)}" y2="${n2(cy + k)}" stroke="${INK}" stroke-width="1.2"/>` +
`<line x1="${n2(cx - k)}" y1="${n2(cy + k)}" x2="${n2(cx + k)}" y2="${n2(cy - k)}" stroke="${INK}" stroke-width="1.2"/>`
);
}
function dotGlyph(part: PlacedPart): string {
const [cu, cv] = proj(view, part.position);
const cx = X(cu);
const cy = Y(cv);
const r = Math.max(0.12 * s, 4);
return (
`<circle cx="${cx}" cy="${cy}" r="${n2(r)}" fill="${partColor(part)}" fill-opacity="0.3" stroke="${INK}" stroke-width="1.1"/>` +
(part.type === 'emitter' ? `<circle cx="${cx}" cy="${cy}" r="1.4" fill="${INK}"/>` : '')
);
}
function boxGlyph(part: PlacedPart): string {
const b = boxes.get(part.id)!;
const rx = X(b.u0);
const ry = Y(b.v1);
const rw = n2(Math.max(X(b.u1) - rx, 3));
const rh = n2(Math.max(Y(b.v0) - ry, 3));
const unknown = !partDef(part.type);
const tent = isTentType(part.type);
const dashed = tent || unknown;
const color = partColor(part);
let g =
`<rect x="${rx}" y="${ry}" width="${rw}" height="${rh}" ` +
`fill="${tent ? 'none' : color}" fill-opacity="0.12" stroke="${INK}" stroke-width="1.4"` +
`${dashed ? ' stroke-dasharray="6 4"' : ''}/>`;
const p = mergedParams(part);
if (part.type === 'tower') {
if (view === 'top') {
g += `<circle cx="${n2(rx + rw / 2)}" cy="${n2(ry + rh / 2)}" r="${n2(Math.min(rw, rh) * 0.32)}" fill="none" stroke="${INK}" stroke-width="1"/>`;
} else {
// Tick marks at plant-site spacing.
const sites = Math.max(1, Math.round(p.sites ?? 6));
for (let i = 1; i < sites; i++) {
const ty = n2(ry + (rh * i) / sites);
g += `<line x1="${rx}" y1="${ty}" x2="${n2(rx + rw * 0.45)}" y2="${ty}" stroke="${INK}" stroke-width="0.8"/>`;
}
}
}
if (part.type === 'reservoir' && view !== 'top') {
const wl = Y(part.position[1] + (p.height ?? 1) * ((p.level ?? 0) / 100));
g += `<line x1="${rx}" y1="${n2(wl)}" x2="${n2(rx + rw)}" y2="${n2(wl)}" stroke="#0284c7" stroke-width="1" stroke-dasharray="4 3"/>`;
}
if (part.type === 'lattice') {
g +=
`<line x1="${rx}" y1="${ry}" x2="${n2(rx + rw)}" y2="${n2(ry + rh)}" stroke="${INK}" stroke-width="0.7"/>` +
`<line x1="${rx}" y1="${n2(ry + rh)}" x2="${n2(rx + rw)}" y2="${ry}" stroke="${INK}" stroke-width="0.7"/>`;
}
if (unknown && rw > 44 && rh > 14) {
g += `<text x="${n2(rx + rw / 2)}" y="${n2(ry + rh / 2 + 3)}" text-anchor="middle" font-size="9" fill="${TEXT}">${escapeXml(partLabel(part))}</text>`;
}
return g;
}
function glyphBody(part: PlacedPart): string {
switch (part.type as string) {
case 'pipe':
return pipeGlyph(part);
case 'elbow':
return elbowGlyph(part);
case 'tee':
return junctionGlyph(part);
case 'pump':
return pumpGlyph(part);
case 'valve':
return valveGlyph(part);
case 'drain':
return drainGlyph(part);
case 'emitter':
case 'netPot':
return dotGlyph(part);
case 'reservoir':
case 'tray':
case 'wallPanel':
case 'tower':
case 'lattice':
return boxGlyph(part);
default: {
// Catalogued-but-unrecognized compact fittings (wye, cross, reducer,
// …) read best as junction symbols; everything else gets its AABB.
const def = partDef(part.type);
const b = boxes.get(part.id)!;
const span = Math.max(b.u1 - b.u0, b.v1 - b.v0);
if (def && safeConnectors(part).length >= 2 && span < 2.2) return junctionGlyph(part);
return boxGlyph(part);
}
}
}
for (const part of drawParts) {
out.push(
`<g class="bs-part" data-part-id="${escapeXml(part.id)}" data-ref="${ref(part.id)}">${glyphBody(part)}</g>`,
);
}
// ----------------------------------------------------------- key bubbles
const placed: { x: number; y: number }[] = [];
for (const part of drawParts) {
const b = boxes.get(part.id)!;
const r = ref(part.id);
const bw2 = r.length * 6 + 8;
let bx = X(b.u1) + 8 + bw2 / 2;
let by = Y(b.v1) - 6;
bx = Math.min(Math.max(bx, x0 + bw2 / 2 + 2), x0 + drawW - bw2 / 2 - 2);
by = Math.min(Math.max(by, y0 + 10), y0 + drawH - 10);
let guard = 0;
while (placed.some((q) => Math.abs(q.x - bx) < 26 && Math.abs(q.y - by) < 17) && guard++ < 40) {
by += 18;
if (by > y0 + drawH - 10) {
by = y0 + 10;
bx += 28;
}
}
placed.push({ x: bx, y: by });
out.push(
`<g class="bs-key" data-part-id="${escapeXml(part.id)}">` +
`<rect x="${n2(bx - bw2 / 2)}" y="${n2(by - 8)}" width="${bw2}" height="16" rx="8" fill="#ffffff" stroke="${DIM}" stroke-width="1.2"/>` +
`<text x="${n2(bx)}" y="${n2(by + 3)}" text-anchor="middle" font-size="8.5" font-weight="700" font-family="${MONO}" fill="${DIM}">${r}</text>` +
`</g>`,
);
}
// ------------------------------------------------------- dimension lines
if (parts.length) {
const exW = u1c - u0c;
const exH = v1c - v0c;
const dimY = y0 + drawH + 24;
const dimX = x0 - 24;
// Horizontal overall dimension.
out.push(
`<line x1="${X(u0c)}" y1="${Y(v0c) + 4}" x2="${X(u0c)}" y2="${dimY + 5}" stroke="${DIM}" stroke-width="0.8"/>`,
`<line x1="${X(u1c)}" y1="${Y(v0c) + 4}" x2="${X(u1c)}" y2="${dimY + 5}" stroke="${DIM}" stroke-width="0.8"/>`,
`<line x1="${X(u0c)}" y1="${dimY}" x2="${X(u1c)}" y2="${dimY}" stroke="${DIM}" stroke-width="1" marker-start="url(#${arrId})" marker-end="url(#${arrId})"/>`,
`<text x="${n2((X(u0c) + X(u1c)) / 2)}" y="${dimY - 5}" text-anchor="middle" font-size="11" font-family="${MONO}" fill="${DIM}">${fmtFtIn(exW)}</text>`,
);
// Vertical overall dimension.
out.push(
`<line x1="${X(u0c) - 4}" y1="${Y(v0c)}" x2="${dimX - 5}" y2="${Y(v0c)}" stroke="${DIM}" stroke-width="0.8"/>`,
`<line x1="${X(u0c) - 4}" y1="${Y(v1c)}" x2="${dimX - 5}" y2="${Y(v1c)}" stroke="${DIM}" stroke-width="0.8"/>`,
`<line x1="${dimX}" y1="${Y(v0c)}" x2="${dimX}" y2="${Y(v1c)}" stroke="${DIM}" stroke-width="1" marker-start="url(#${arrId})" marker-end="url(#${arrId})"/>`,
`<text x="${dimX - 6}" y="${n2((Y(v0c) + Y(v1c)) / 2)}" text-anchor="middle" font-size="11" font-family="${MONO}" fill="${DIM}" transform="rotate(-90 ${dimX - 6} ${n2((Y(v0c) + Y(v1c)) / 2)})">${fmtFtIn(exH)}</text>`,
);
// Elevation callouts (front/side): reservoir rims, tower tops, highest pipe.
if (view !== 'top') {
const callouts: { y: number; label: string }[] = [];
let highPipe = -Infinity;
for (const p of parts) {
const a = worldAABB(p);
if (p.type === 'reservoir') callouts.push({ y: a.max[1], label: `${ref(p.id)} RIM` });
if (p.type === 'tower') callouts.push({ y: a.max[1], label: `${ref(p.id)} TOP` });
if (p.type === 'pipe') highPipe = Math.max(highPipe, a.max[1]);
}
if (highPipe > 0.5) callouts.push({ y: highPipe, label: 'HIGH PIPE' });
callouts.sort((a, b) => b.y - a.y);
let lastTy = -Infinity;
for (const c of callouts) {
const ly = Y(c.y);
let ty = ly + 3;
if (ty - lastTy < 11) ty = lastTy + 11;
lastTy = ty;
out.push(
`<line x1="${x0}" y1="${n2(ly)}" x2="${x0 + drawW + 6}" y2="${n2(ly)}" stroke="${DIM}" stroke-width="0.7" stroke-dasharray="7 4" opacity="0.65"/>`,
`<text x="${x0 + drawW + 10}" y="${n2(ty)}" font-size="8" font-family="${MONO}" fill="${DIM}">${escapeXml(c.label)} ${fmtFtIn(c.y)}</text>`,
);
}
}
}
// Drawing frame on top of the grid.
out.push(`<rect x="${x0}" y="${y0}" width="${drawW}" height="${drawH}" fill="none" stroke="${FRAME}" stroke-width="1.2"/>`);
if (!parts.length) {
out.push(
`<text x="${x0 + drawW / 2}" y="${y0 + drawH / 2}" text-anchor="middle" font-size="14" fill="${TEXT}">NO PARTS PLACED</text>`,
);
}
// ----------------------------------------------------------------- legend
const legend: LegendRow[] = [...parts]
.sort((a, b) => (keys.get(a.id)?.num ?? 0) - (keys.get(b.id)?.num ?? 0))
.map((p) => ({
partId: p.id,
ref: ref(p.id),
label: partLabel(p),
type: p.type,
size: partSizeText(p),
}));
const rowH = 19;
const cols = legend.length > 12 ? 2 : 1;
const rowsPerCol = Math.max(1, Math.ceil(legend.length / cols));
const yLeg = y0 + drawH + dimB;
const legendH = legend.length ? 26 + rowsPerCol * rowH : 18;
const H = yLeg + legendH + margin;
const colW = (W - margin * 2) / cols;
out.push(
`<line x1="${margin}" y1="${yLeg}" x2="${W - margin}" y2="${yLeg}" stroke="${FRAME}" stroke-width="0.8"/>`,
`<text x="${margin}" y="${yLeg + 16}" font-size="10" font-weight="700" letter-spacing="1.5" fill="${INK}">PARTS KEY</text>`,
);
legend.forEach((row, i) => {
const col = Math.floor(i / rowsPerCol);
const rx = margin + col * colW;
const ry = yLeg + 26 + (i - col * rowsPerCol) * rowH + 6;
const bw2 = row.ref.length * 6 + 8;
out.push(
`<g class="bs-legend-row" data-part-id="${escapeXml(row.partId)}">` +
`<rect x="${rx}" y="${ry - 4}" width="${bw2}" height="14" rx="7" fill="#ffffff" stroke="${DIM}" stroke-width="1"/>` +
`<text x="${n2(rx + bw2 / 2)}" y="${ry + 6}" text-anchor="middle" font-size="8" font-weight="700" font-family="${MONO}" fill="${DIM}">${row.ref}</text>` +
`<text x="${rx + 42}" y="${ry + 7}" font-size="9.5" font-weight="600" fill="${INK}">${escapeXml(row.label.slice(0, 26))}</text>` +
`<text x="${rx + 200}" y="${ry + 7}" font-size="8.5" fill="${TEXT}">${escapeXml(row.type)}</text>` +
`<text x="${rx + 300}" y="${ry + 7}" font-size="8.5" font-family="${MONO}" fill="${TEXT}">${escapeXml(row.size)}</text>` +
`</g>`,
);
});
// ----------------------------------------------------------------- header
const inPerFt = s / 96; // assuming 96 px/in for the printed-scale note
const scaleNote = `SCALE ${inPerFt >= 0.995 ? inPerFt.toFixed(1) : inPerFt.toFixed(2)} in = 1 ft · GRID 1 ft`;
const header =
`<text x="${margin}" y="${margin + 8}" font-size="16" font-weight="800" fill="${INK}">${escapeXml(title)}</text>` +
`<text x="${margin}" y="${margin + 24}" font-size="9" letter-spacing="1" fill="${TEXT}">HYDRO BUILDER · BUILD SHEET · ${parts.length} PARTS</text>` +
`<text x="${W - margin}" y="${margin + 8}" text-anchor="end" font-size="13" font-weight="700" fill="${INK}">${VIEW_NAME[view]}</text>` +
`<text x="${W - margin}" y="${margin + 24}" text-anchor="end" font-size="9" font-family="${MONO}" fill="${TEXT}">${scaleNote}</text>` +
`<line x1="${margin}" y1="${headerH}" x2="${W - margin}" y2="${headerH}" stroke="${FRAME}" stroke-width="1"/>`;
const svg =
`<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" ` +
`font-family="ui-sans-serif, system-ui, sans-serif">` +
`<defs><marker id="${arrId}" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">` +
`<path d="M0,0 L10,5 L0,10 z" fill="${DIM}"/></marker></defs>` +
`<rect x="0" y="0" width="${W}" height="${H}" fill="#ffffff"/>` +
`<rect x="6" y="6" width="${W - 12}" height="${H - 12}" fill="none" stroke="${FRAME}" stroke-width="1.5"/>` +
header +
out.join('') +
`</svg>`;
return {
svg,
width: W,
height: H,
view,
pxPerFt: s,
extents: { w: parts.length ? u1c - u0c : 0, h: parts.length ? v1c - v0c : 0 },
legend,
};
}

301
src/buildsheets/common.ts Normal file
View File

@@ -0,0 +1,301 @@
/**
* Shared helpers for the Build Sheets feature (blueprint drawings +
* assembly instructions).
*
* Self-contained: depends only on src/types.ts, the part catalog, and the
* connector utils. Unknown part types (added to the catalog by other code
* at runtime, or entirely uncatalogued) are handled generically and never
* throw.
*/
import type { PartDefinition, PlacedPart, Vec3, WorldConnector } from '../types';
import { PART_DEFS } from '../parts/catalog';
import { CONNECT_DIST, dist3, getWorldConnectors } from '../utils/connectors';
/** Catalog lookup that tolerates part types unknown at compile time. */
const DEFS = PART_DEFS as Record<string, PartDefinition | undefined>;
export function partDef(type: string): PartDefinition | undefined {
return DEFS[type];
}
export function partLabel(p: PlacedPart): string {
return p.label || partDef(p.type)?.label || p.type;
}
export function partColor(p: PlacedPart): string {
return p.color || partDef(p.type)?.color || '#94a3b8';
}
/** Part params backed by catalog defaults (when the type is catalogued). */
export function mergedParams(p: PlacedPart): Record<string, number> {
return { ...(partDef(p.type)?.defaults ?? {}), ...p.params };
}
/** World-space connectors; uncatalogued types yield [] instead of throwing. */
export function safeConnectors(p: PlacedPart): WorldConnector[] {
if (!partDef(p.type)) return [];
try {
return getWorldConnectors(p);
} catch {
return [];
}
}
// ---------------------------------------------------------------- geometry
const snapAxis = (v: number) => {
if (Math.abs(v) < 1e-9) return 0;
const r = Math.round(v);
return Math.abs(v - r) < 1e-9 ? r : v;
};
/**
* Rotate a vector by an XYZ-order euler — matches three.js Euler 'XYZ'
* (R = Rx·Ry·Rz applied to a column vector: Rz first, then Ry, then Rx).
* Components are snapped so 90°-multiple rotations stay exactly axis-aligned.
*/
export function rotateVec(rot: Vec3, v: Vec3): Vec3 {
let [x, y, z] = v;
const [rx, ry, rz] = rot;
let c = Math.cos(rz);
let s = Math.sin(rz);
[x, y] = [x * c - y * s, x * s + y * c];
c = Math.cos(ry);
s = Math.sin(ry);
[x, z] = [x * c + z * s, -x * s + z * c];
c = Math.cos(rx);
s = Math.sin(rx);
[y, z] = [y * c - z * s, y * s + z * c];
return [snapAxis(x), snapAxis(y), snapAxis(z)];
}
export interface Box3 {
min: Vec3;
max: Vec3;
}
const box = (
x0: number, y0: number, z0: number,
x1: number, y1: number, z1: number,
): Box3 => ({ min: [x0, y0, z0], max: [x1, y1, z1] });
/** Bend radius used by elbows (mirrors catalog.ts ELBOW_BEND_RADIUS). */
export const BEND_RADIUS = 0.5;
/**
* Local-space bounding box of a part (ft), derived from its params.
* Known types get tuned footprints; unknown types get a generic box from
* common params (width/depth/height/length/size) expanded to reach all of
* the part's local connectors.
*/
export function localBox(part: PlacedPart): Box3 {
const p = mergedParams(part);
const dia = (p.diameter ?? 1) / 12;
const r = Math.max(dia / 2, 0.04);
switch (part.type as string) {
case 'pipe':
return box(-(p.length ?? 1) / 2, -r, -r, (p.length ?? 1) / 2, r, r);
case 'elbow':
return box(-r, -r, -r, BEND_RADIUS + r, BEND_RADIUS + r, r);
case 'tee':
return box(-0.6, -r, -r, 0.6, r, 0.6);
case 'valve':
return box(-0.35, -0.18, -0.18, 0.35, 0.18, 0.18);
case 'pump':
return box(-0.45, 0, -0.25, 0.45, 0.5, 0.25);
case 'reservoir':
return box(-(p.width ?? 2) / 2, 0, -(p.depth ?? 1.4) / 2, (p.width ?? 2) / 2, p.height ?? 1, (p.depth ?? 1.4) / 2);
case 'tower':
return box(-0.3, 0, -0.3, 0.3, p.height ?? 4, 0.3);
case 'tray':
return box(-(p.length ?? 3) / 2, 0, -(p.width ?? 1.2) / 2, (p.length ?? 3) / 2, 0.25, (p.width ?? 1.2) / 2);
case 'wallPanel':
return box(-(p.width ?? 3) / 2, 0, -0.15, (p.width ?? 3) / 2, p.height ?? 4, 0.15);
case 'lattice':
return box(-(p.width ?? 2.5) / 2, 0, -0.08, (p.width ?? 2.5) / 2, p.height ?? 3, 0.08);
case 'netPot': {
const s = (p.size ?? 1) * 0.22;
return box(-s, 0, -s, s, s * 1.2, s);
}
case 'emitter':
return box(-0.15, -0.15, -0.15, 0.2, 0.15, 0.15);
case 'drain':
return box(-0.35, 0, -0.3, 0.3, 0.3, 0.3);
default: {
const w = p.width ?? p.length ?? p.size ?? 1;
const d = p.depth ?? p.width ?? p.size ?? 1;
const h = p.height ?? p.size ?? 1;
const b = box(-w / 2, 0, -d / 2, w / 2, h, d / 2);
// Make sure the box reaches every local connector.
try {
for (const c of partDef(part.type)?.getConnectors(p) ?? []) {
for (let i = 0; i < 3; i++) {
b.min[i] = Math.min(b.min[i], c.pos[i]);
b.max[i] = Math.max(b.max[i], c.pos[i]);
}
}
} catch {
/* tolerate misbehaving catalog entries */
}
return b;
}
}
}
/** World-space AABB: rotate the 8 local-box corners, then translate. */
export function worldAABB(part: PlacedPart): Box3 {
const lb = localBox(part);
const min: Vec3 = [Infinity, Infinity, Infinity];
const max: Vec3 = [-Infinity, -Infinity, -Infinity];
for (const cx of [lb.min[0], lb.max[0]])
for (const cy of [lb.min[1], lb.max[1]])
for (const cz of [lb.min[2], lb.max[2]]) {
const w = rotateVec(part.rotation, [cx, cy, cz]);
for (let i = 0; i < 3; i++) {
const v = w[i] + part.position[i];
if (v < min[i]) min[i] = v;
if (v > max[i]) max[i] = v;
}
}
return { min, max };
}
// -------------------------------------------------------------- formatting
const trimNum = (n: number) =>
Number.isInteger(n) ? String(n) : String(Math.round(n * 100) / 100);
/** Feet+inches text, e.g. 6.5 → 6'-6", 0.75 → 9". Rounds to 1/4 inch. */
export function fmtFtIn(ftVal: number): string {
const sign = ftVal < -1e-9 ? '-' : '';
const quarters = Math.round(Math.abs(ftVal) * 48);
const feet = Math.floor(quarters / 48);
const inches = (quarters - feet * 48) / 4;
if (feet === 0) return `${sign}${trimNum(inches)}"`;
return `${sign}${feet}'-${trimNum(inches)}"`;
}
/** Inches-only text, e.g. 2 (ft) → "24 in". Rounds to 1/4 inch. */
export function fmtInches(ftVal: number): string {
return `${trimNum(Math.round(ftVal * 48) / 4)} in`;
}
export function escapeXml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// -------------------------------------------------------------------- keys
export interface PartKey {
/** Short blueprint reference, e.g. "P3", "R1". */
ref: string;
/** Global 1-based legend index. */
num: number;
}
const REF_PREFIX: Record<string, string> = {
pipe: 'P',
elbow: 'E',
tee: 'J',
wye: 'WY',
cross: 'X',
reducer: 'RD',
valve: 'V',
pump: 'PM',
reservoir: 'R',
tower: 'T',
tray: 'TY',
wallPanel: 'W',
lattice: 'L',
netPot: 'N',
emitter: 'EM',
drain: 'D',
};
/**
* Deterministic key assignment: parts sorted by type, then position, then
* id; numbered per type prefix (P1, P2, ... / R1 / T1 ...).
*/
export function assignKeys(parts: PlacedPart[]): Map<string, PartKey> {
const sorted = [...parts].sort(
(a, b) =>
a.type.localeCompare(b.type) ||
a.position[0] - b.position[0] ||
a.position[2] - b.position[2] ||
a.position[1] - b.position[1] ||
a.id.localeCompare(b.id),
);
const counters: Record<string, number> = {};
const map = new Map<string, PartKey>();
sorted.forEach((p, i) => {
const prefix = REF_PREFIX[p.type] ?? p.type.slice(0, 2).toUpperCase();
counters[prefix] = (counters[prefix] ?? 0) + 1;
map.set(p.id, { ref: `${prefix}${counters[prefix]}`, num: i + 1 });
});
return map;
}
/** Human size string for the legend / cut lists. */
export function partSizeText(part: PlacedPart): string {
const p = mergedParams(part);
switch (part.type as string) {
case 'pipe':
return `${fmtFtIn(p.length ?? 0)} × ${p.diameter ?? 1}" dia`;
case 'elbow':
return `${p.angle ?? 90}° × ${p.diameter ?? 1}"`;
case 'tee':
case 'wye':
case 'cross':
return `${p.diameter ?? 1}"`;
case 'valve':
return `${p.diameter ?? 1}" · ${p.open ?? 100}% open`;
case 'pump':
return `${p.gph ?? 0} GPH / ${p.maxHeadFt ?? 0} ft head`;
case 'reservoir':
return `${fmtFtIn(p.width ?? 0)} × ${fmtFtIn(p.depth ?? 0)} × ${fmtFtIn(p.height ?? 0)}`;
case 'tower':
return `${fmtFtIn(p.height ?? 0)} tall · ${p.sites ?? 0} sites`;
case 'tray':
return `${fmtFtIn(p.length ?? 0)} × ${fmtFtIn(p.width ?? 0)}`;
case 'wallPanel':
case 'lattice':
return `${fmtFtIn(p.width ?? 0)} × ${fmtFtIn(p.height ?? 0)}`;
case 'drain':
return `${p.capacityGph ?? 0} GPH cap`;
default: {
const dims: string[] = [];
if (p.length != null) dims.push(fmtFtIn(p.length));
if (p.width != null) dims.push(fmtFtIn(p.width));
if (p.depth != null) dims.push(fmtFtIn(p.depth));
if (p.height != null) dims.push(fmtFtIn(p.height));
if (p.diameter != null) dims.push(`${p.diameter}" dia`);
return dims.length ? dims.join(' × ') : '—';
}
}
}
// ------------------------------------------------------------- connections
export interface Connection {
a: WorldConnector;
b: WorldConnector;
}
/**
* All joined connector pairs: connectors of different parts within
* CONNECT_DIST of each other. Each unordered pair appears exactly once.
*/
export function findConnections(parts: PlacedPart[]): Connection[] {
const all = parts.flatMap(safeConnectors);
const out: Connection[] = [];
for (let i = 0; i < all.length; i++)
for (let j = i + 1; j < all.length; j++) {
if (all[i].partId === all[j].partId) continue;
if (dist3(all[i].pos, all[j].pos) < CONNECT_DIST) out.push({ a: all[i], b: all[j] });
}
return out;
}

View File

@@ -0,0 +1,110 @@
import { useEffect, useRef } from 'react';
import { useBuilder } from '../store/builderStore';
import { PART_DEFS } from '../parts/catalog';
/** Curated palette offered for part recoloring. */
export const COLOR_PRESETS = [
'#b8bfc9', // steel gray
'#64748b', // slate
'#38bdf8', // sky
'#3d7bfa', // blue
'#34d399', // emerald
'#3b7a57', // leaf green
'#fbbf24', // amber
'#f97316', // orange
'#e0564f', // red
'#a78bfa', // violet
];
/**
* Swatch row shared by the floating popover and the inspector: presets,
* a native color picker, and a reset-to-catalog-color action.
*/
export function ColorSwatchRow({ ids }: { ids: string[] }) {
const applyColor = useBuilder((s) => s.applyColor);
const current = useBuilder((s) => {
const first = ids.length ? s.parts[ids[0]] : undefined;
return first ? (first.color ?? PART_DEFS[first.type].color) : '#888888';
});
return (
<div className="space-y-1.5">
<div className="grid grid-cols-5 gap-1.5">
{COLOR_PRESETS.map((c) => (
<button
key={c}
title={c}
onClick={() => applyColor(ids, c)}
className={`h-6 w-full rounded-md ring-1 transition hover:scale-110 ${
current.toLowerCase() === c ? 'ring-2 ring-white' : 'ring-black/40'
}`}
style={{ backgroundColor: c }}
/>
))}
</div>
<div className="flex items-center gap-1.5">
<label
className="relative flex h-6 flex-1 cursor-pointer items-center justify-center gap-1 overflow-hidden rounded-md border border-zinc-700 bg-zinc-800 text-[10px] font-medium text-zinc-300 hover:bg-zinc-700"
title="Pick a custom color"
>
<span
className="h-3 w-3 rounded-sm ring-1 ring-black/40"
style={{ backgroundColor: current }}
/>
Custom
<input
type="color"
value={current}
onChange={(e) => applyColor(ids, e.target.value)}
className="absolute inset-0 cursor-pointer opacity-0"
/>
</label>
<button
onClick={() => applyColor(ids, undefined)}
title="Reset to the catalog color"
className="h-6 rounded-md border border-zinc-700 bg-zinc-800 px-2 text-[10px] font-medium text-zinc-300 hover:bg-zinc-700"
>
Reset
</button>
</div>
</div>
);
}
/**
* ColorPopover — small floating palette opened by cmd/ctrl+clicking a part.
* Applies to every selected part; closes on outside click or Esc (App).
*/
export function ColorPopover() {
const popover = useBuilder((s) => s.colorPopover);
const selectedIds = useBuilder((s) => s.selectedIds);
const close = useBuilder((s) => s.closeColorPopover);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!popover) return;
const onDown = (e: PointerEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) close();
};
window.addEventListener('pointerdown', onDown, true);
return () => window.removeEventListener('pointerdown', onDown, true);
}, [popover, close]);
if (!popover || !selectedIds.length) return null;
const x = Math.max(8, Math.min(popover.x, window.innerWidth - 196));
const y = Math.max(8, Math.min(popover.y + 10, window.innerHeight - 150));
return (
<div
ref={ref}
className="fixed z-50 w-[188px] rounded-xl border border-zinc-700 bg-zinc-900/95 p-2.5 shadow-2xl backdrop-blur"
style={{ left: x, top: y }}
>
<div className="mb-1.5 text-[10px] font-bold tracking-wider text-zinc-500 uppercase">
Color · {selectedIds.length} part{selectedIds.length > 1 ? 's' : ''}
</div>
<ColorSwatchRow ids={selectedIds} />
</div>
);
}

View File

@@ -0,0 +1,285 @@
import { useMemo, useRef, useState } from 'react';
import * as THREE from 'three';
import { Html, Line, useCursor } from '@react-three/drei';
import { useFrame } from '@react-three/fiber';
import type { ThreeEvent } from '@react-three/fiber';
import type { WorldConnector } from '../types';
import { useBuilder } from '../store/builderStore';
import { getWorldConnectors } from '../utils/connectors';
import { beginPartDrag, dragCtx } from '../utils/dragState';
/**
* ConnectorHandles — grabbable spheres at every connector of the selected
* parts. Dragging a handle moves the WHOLE part (and the rest of the
* selection, rigidly) so the grabbed connector follows the cursor; the
* DragPlane in SceneCanvas does the actual plane math, live-highlights the
* nearest open mating connector, and magnetically snaps on release.
*/
export function ConnectorHandles() {
const selectedIds = useBuilder((s) => s.selectedIds);
const parts = useBuilder((s) => s.parts);
const placingType = useBuilder((s) => s.placingType);
const tool = useBuilder((s) => s.tool);
if (placingType || tool === 'measure' || !selectedIds.length) return null;
const conns: WorldConnector[] = [];
for (const id of selectedIds) {
const part = parts[id];
if (part) conns.push(...getWorldConnectors(part));
}
const pipeIds = selectedIds.filter((id) => parts[id]?.type === 'pipe');
return (
<>
{pipeIds.map((id) => (
<PipeStretchHandles key={`pipe-stretch-${id}`} partId={id} />
))}
{conns.map((c) => (
<Handle key={c.key} conn={c} dimmed={parts[c.partId]?.type === 'pipe'} />
))}
<SnapTargetHighlight />
</>
);
}
const dirQuat = (dir: [number, number, number]) =>
new THREE.Quaternion().setFromUnitVectors(
new THREE.Vector3(0, 0, 1),
new THREE.Vector3(...dir).normalize(),
);
function Handle({ conn, dimmed = false }: { conn: WorldConnector; dimmed?: boolean }) {
const draggingId = useBuilder((s) => s.draggingId);
const [hovered, setHovered] = useState(false);
useCursor(hovered, 'grab');
const quat = useMemo(() => dirQuat(conn.dir), [conn.dir[0], conn.dir[1], conn.dir[2]]);
const isDragSource = draggingId !== null && dragCtx.connectorKey === conn.key;
const active = hovered || isDragSource;
const onPointerDown = (e: ThreeEvent<PointerEvent>) => {
if (e.button !== 0) return;
const s = useBuilder.getState();
if (s.placingType || s.tool === 'measure') return;
e.stopPropagation();
const startX = e.clientX;
const startY = e.clientY;
const handlePointerUp = (upEvt: PointerEvent) => {
window.removeEventListener('pointerup', handlePointerUp);
const dx = upEvt.clientX - startX;
const dy = upEvt.clientY - startY;
const dist = Math.hypot(dx, dy);
if (dist < 4) {
s.spawnPipeAtConnector(conn.key);
}
};
window.addEventListener('pointerup', handlePointerUp);
beginPartDrag({
partId: conn.partId,
grabPoint: conn.pos,
mode: 'handle',
connectorKey: conn.key,
});
};
return (
<group position={conn.pos}>
{/* Generous invisible hit sphere (raycasts despite invisible material). */}
<mesh
userData={{ connectorHandle: true }}
onPointerDown={onPointerDown}
onPointerOver={(e) => {
// No stopPropagation (it would starve the DragPlane of move events
// mid-drag). Handles take hover priority over part bodies when they
// are roughly as close as the nearest hit (mirrors the
// PartMesh pointerdown guard).
if (useBuilder.getState().draggingId) return;
const firstHandle = e.intersections.find(
(i) => i.object.userData?.connectorHandle,
);
if (
firstHandle?.object !== e.object ||
firstHandle.distance > (e.intersections[0]?.distance ?? Infinity) + 0.5
)
return;
setHovered(true);
}}
onPointerOut={() => setHovered(false)}
>
<sphereGeometry args={[0.22, 10, 10]} />
<meshBasicMaterial visible={false} />
</mesh>
{/* Visible grab knob, always on top so it's never buried in geometry. */}
<mesh scale={active ? 1.4 : 1} renderOrder={998}>
<sphereGeometry args={[dimmed ? 0.05 : 0.075, 14, 14]} />
<meshBasicMaterial
color={active ? '#bae6fd' : '#38bdf8'}
toneMapped={false}
depthTest={false}
transparent
opacity={dimmed ? (active ? 0.85 : 0.45) : 0.95}
/>
</mesh>
{/* Direction ring hinting how the connector faces. */}
<mesh quaternion={quat} scale={active ? 1.25 : 1} renderOrder={998}>
<torusGeometry args={[dimmed ? 0.11 : 0.15, dimmed ? 0.014 : 0.018, 8, 24]} />
<meshBasicMaterial
color="#38bdf8"
toneMapped={false}
depthTest={false}
transparent
opacity={dimmed ? (active ? 0.8 : 0.35) : active ? 0.95 : 0.55}
/>
</mesh>
</group>
);
}
function PipeStretchHandles({ partId }: { partId: string }) {
const part = useBuilder((s) => s.parts[partId]);
const draggingId = useBuilder((s) => s.draggingId);
if (!part || part.type !== 'pipe') return null;
const conns = getWorldConnectors(part);
const a = conns.find((c) => c.connectorId === 'a');
const b = conns.find((c) => c.connectorId === 'b');
if (!a || !b) return null;
const len = part.params.length ?? 0;
const mid: [number, number, number] = [
(a.pos[0] + b.pos[0]) / 2,
(a.pos[1] + b.pos[1]) / 2 + 0.28,
(a.pos[2] + b.pos[2]) / 2,
];
const editing = draggingId === partId && !!dragCtx.pipeStretch;
return (
<group>
<Line
points={[a.pos, b.pos]}
color={editing ? '#7dd3fc' : '#38bdf8'}
lineWidth={2}
transparent
opacity={editing ? 0.95 : 0.55}
/>
<PipeEndHandle conn={a} />
<PipeEndHandle conn={b} />
<Html position={mid} center distanceFactor={12}>
<div
className={`rounded-full border px-2 py-1 text-[10px] font-semibold whitespace-nowrap shadow-lg backdrop-blur ${
editing
? 'border-sky-400 bg-sky-300/95 text-sky-950'
: 'border-zinc-700 bg-zinc-950/90 text-zinc-200'
}`}
>
{len.toFixed(2)} ft
<span className="ml-1 text-zinc-400">{editing ? 'stretching' : 'drag ends to stretch'}</span>
</div>
</Html>
</group>
);
}
function PipeEndHandle({ conn }: { conn: WorldConnector }) {
const draggingId = useBuilder((s) => s.draggingId);
const [hovered, setHovered] = useState(false);
useCursor(hovered, 'grab');
const quat = useMemo(() => dirQuat(conn.dir), [conn.dir[0], conn.dir[1], conn.dir[2]]);
const active = hovered || (draggingId !== null && dragCtx.connectorKey === conn.key);
const onPointerDown = (e: ThreeEvent<PointerEvent>) => {
if (e.button !== 0) return;
const s = useBuilder.getState();
if (s.placingType || s.tool === 'measure') return;
e.stopPropagation();
beginPartDrag({
partId: conn.partId,
grabPoint: conn.pos,
mode: 'handle',
connectorKey: conn.key,
});
};
return (
<group position={conn.pos}>
<mesh
userData={{ connectorHandle: true }}
onPointerDown={onPointerDown}
onPointerOver={(e) => {
if (useBuilder.getState().draggingId) return;
const firstHandle = e.intersections.find((i) => i.object.userData?.connectorHandle);
if (
firstHandle?.object !== e.object ||
firstHandle.distance > (e.intersections[0]?.distance ?? Infinity) + 0.5
)
return;
setHovered(true);
}}
onPointerOut={() => setHovered(false)}
>
<sphereGeometry args={[0.28, 12, 12]} />
<meshBasicMaterial visible={false} />
</mesh>
<mesh quaternion={quat} scale={active ? 1.12 : 1} renderOrder={998}>
<cylinderGeometry args={[0.05, 0.1, 0.18, 12]} />
<meshBasicMaterial
color={active ? '#e0f2fe' : '#7dd3fc'}
toneMapped={false}
depthTest={false}
transparent
opacity={0.98}
/>
</mesh>
<mesh quaternion={quat} position={[0, 0, 0.11]} scale={active ? 1.18 : 1} renderOrder={998}>
<coneGeometry args={[0.08, 0.16, 12]} />
<meshBasicMaterial
color={active ? '#38bdf8' : '#0ea5e9'}
toneMapped={false}
depthTest={false}
transparent
opacity={0.95}
/>
</mesh>
</group>
);
}
/** Pulsing glow ring on the nearest open connector during a handle drag. */
function SnapTargetHighlight() {
const key = useBuilder((s) => s.snapTargetKey);
const parts = useBuilder((s) => s.parts);
const ref = useRef<THREE.Group>(null);
useFrame(({ clock }) => {
if (ref.current) {
const k = 1 + 0.18 * Math.sin(clock.elapsedTime * 7);
ref.current.scale.setScalar(k);
}
});
if (!key) return null;
const partId = key.split(':')[0];
const part = parts[partId];
if (!part) return null;
const conn = getWorldConnectors(part).find((c) => c.key === key);
if (!conn) return null;
return (
<group position={conn.pos}>
<group ref={ref}>
<mesh quaternion={dirQuat(conn.dir)} renderOrder={999}>
<torusGeometry args={[0.24, 0.035, 10, 28]} />
<meshBasicMaterial color="#34d399" toneMapped={false} depthTest={false} transparent opacity={0.95} />
</mesh>
<mesh renderOrder={999}>
<sphereGeometry args={[0.1, 12, 12]} />
<meshBasicMaterial color="#6ee7b7" toneMapped={false} depthTest={false} transparent opacity={0.9} />
</mesh>
</group>
</group>
);
}

View File

@@ -0,0 +1,251 @@
import { useMemo, useRef } from 'react';
import { useFrame } from '@react-three/fiber';
import * as THREE from 'three';
import type { FlowSegment, PlacedPart, SimResult } from '../types';
import { useBuilder } from '../store/builderStore';
import { useSimulation } from '../simulation/flowSimulator';
import {
ensureWaterVisual,
freezeWaterClock,
getWaterTime,
getWaterVisual,
tickWaterClock,
waterVisuals,
} from '../simulation/waterState';
/**
* FlowArrows — mount root for all time-based water visual systems:
*
* - WaterDriver (always mounted): advances a global fill wavefront each
* frame and writes per-part fill state into the shared water map
* (simulation/waterState), which the meshes in PartBody read in their
* own useFrame callbacks. No React state is touched per frame.
* - Direction arrows (gated by the showFlow toggle): small cones marching
* along flowing segments, fading in/out at segment ends and appearing
* only once the pipe has actually filled with water.
*/
export function FlowArrows() {
const showFlow = useBuilder((s) => s.showFlow);
const parts = useBuilder((s) => s.parts);
const sim = useSimulation();
return (
<group>
<WaterDriver sim={sim} parts={parts} />
{showFlow &&
sim.segments.map((seg, i) => <ArrowStream key={`${seg.partId}-${i}`} seg={seg} />)}
</group>
);
}
// ---------------------------------------------------------------------------
// Water fill driver
// ---------------------------------------------------------------------------
// Module-level so remounts / panel toggles don't reset the animation.
let waveFront = 0;
const clamp01 = (x: number) => (x < 0 ? 0 : x > 1 ? 1 : x);
const clamp = (x: number, lo: number, hi: number) => (x < lo ? lo : x > hi ? hi : x);
const GAL_PER_FT3 = 7.48;
/** Approximate internal water volume (gal) — sets how fast a part fills. */
function partVolumeGal(part: PlacedPart, pathLen: number): number {
const p = part.params;
const d = Math.max(0.25, p.diameter ?? 1);
switch (part.type) {
case 'pipe':
case 'elbow':
case 'tee':
case 'valve':
// Bore volume: π·r² × path length (d inches → ft).
return Math.PI * Math.pow(d / 24, 2) * pathLen * GAL_PER_FT3;
case 'pump':
return 0.06;
case 'tower':
return 0.1 * (p.height ?? 4);
case 'tray':
return 0.08 * (p.length ?? 3) * (p.width ?? 1.2);
case 'wallPanel':
return 0.25;
case 'emitter':
return 0.02;
case 'drain':
return 0.05;
case 'reservoir':
return 2;
default:
return 0.1;
}
}
interface WaveSpan {
dist: number;
len: number;
/** Wavefront speed through this part (ft/s) ≈ len · GPH / volume. */
speed: number;
}
function WaterDriver({ sim, parts }: { sim: SimResult; parts: Record<string, PlacedPart> }) {
const simRef = useRef(sim);
simRef.current = sim;
// Per-part fill data, recomputed only when the sim solution changes.
const wave = useMemo(() => {
const partIds = Object.keys(sim.fillLength);
const spans: WaveSpan[] = [];
for (const pid of partIds) {
const dist = sim.fillDistance[pid];
const flow = sim.flows[pid] ?? 0;
const part = parts[pid];
if (dist === undefined || flow <= 0 || !part) continue;
const len = Math.max(0.2, sim.fillLength[pid]);
const vol = Math.max(0.004, partVolumeGal(part, len));
spans.push({ dist, len, speed: clamp((len * (flow / 3600)) / vol, 0.5, 5) });
}
return { partIds, spans };
}, [sim, parts]);
const waveRef = useRef(wave);
waveRef.current = wave;
useFrame((_, rawDt) => {
// Master play/pause: freeze ALL water animation in place.
if (!useBuilder.getState().simRunning) {
freezeWaterClock();
return;
}
const s = simRef.current;
const dt = Math.min(rawDt, 0.1);
tickWaterClock(dt);
// Advance the wavefront while pumping; retreat (drain back) when idle.
// The front moves at the speed of the slowest part it is currently
// filling (∝ GPH / part volume), so fat tanks fill slower than thin pipes.
const flowing = s.totalGph > 0.5;
if (flowing) {
let speed = Infinity;
for (const span of waveRef.current.spans) {
if (waveFront >= span.dist && waveFront < span.dist + span.len) {
speed = Math.min(speed, span.speed);
}
}
if (!Number.isFinite(speed)) speed = 2; // bridging a junction gap
waveFront = Math.min(waveFront + speed * dt, s.totalPathLength + 1);
} else {
waveFront = Math.max(waveFront - 5 * dt, 0);
}
for (const pid of waveRef.current.partIds) ensureWaterVisual(pid);
for (const [pid, v] of waterVisuals) {
if (!(pid in s.fillLength)) {
// Part was deleted — let its water vanish, then drop the entry.
v.fill = Math.max(0, v.fill - dt * 1.5);
if (v.fill <= 0) waterVisuals.delete(pid);
continue;
}
const flow = s.flows[pid] ?? 0;
const backed = s.backedUp.has(pid);
const dist = s.fillDistance[pid];
const len = Math.max(0.2, s.fillLength[pid]);
if (flow > 0 && dist !== undefined) {
// A part D ft from the pump fills once the wavefront passes D.
v.fill = clamp01((waveFront - dist) / len);
} else if (backed) {
v.fill = Math.min(1, v.fill + dt * 0.6); // stagnant water backing up
} else {
v.fill = Math.max(0, v.fill - dt * 0.9); // no supply — drain out
}
v.flow = flow;
v.backedUp = backed;
v.overflow = false;
v.spill = false;
v.entry = s.entryConnector[pid];
v.state = backed
? 'backedUp'
: v.fill <= 0.002
? 'empty'
: v.fill >= 0.998
? 'full'
: 'filling';
}
for (const d of s.drains) {
if (!d.overflowing) continue;
const v = waterVisuals.get(d.partId);
if (v) v.overflow = true;
}
for (const pot of s.netPots) {
if (!pot.overflowing) continue;
const v = ensureWaterVisual(pot.partId);
v.fill = 1;
v.flow = pot.inflowGph;
v.backedUp = true;
v.overflow = false;
v.spill = true;
v.state = 'backedUp';
}
});
return null;
}
// ---------------------------------------------------------------------------
// Direction arrows
// ---------------------------------------------------------------------------
const UP = new THREE.Vector3(0, 1, 0);
function ArrowStream({ seg }: { seg: FlowSegment }) {
const groupRef = useRef<THREE.Group>(null);
const { from, dir, len, quat, count, speed } = useMemo(() => {
const from = new THREE.Vector3(...seg.from);
const to = new THREE.Vector3(...seg.to);
const dir = to.clone().sub(from);
const len = dir.length();
dir.normalize();
// Arrow speed tracks water VELOCITY (∝ GPH / d²), not raw GPH — the same
// flow squeezed through a narrow pipe visibly rushes.
const dia = Math.max(0.5, seg.diameter ?? 1);
return {
from,
dir,
len,
quat: new THREE.Quaternion().setFromUnitVectors(UP, dir),
count: Math.max(1, Math.round(len / 0.55)),
speed: 0.35 + Math.min(2.5, seg.gph / (dia * dia) / 220),
};
}, [seg]);
useFrame(() => {
const g = groupRef.current;
if (!g) return;
const t = getWaterTime() * speed;
// Arrows only appear once the water has actually reached this part.
const fill = getWaterVisual(seg.partId)?.fill ?? 1;
g.children.forEach((child, i) => {
const f = (((i + t) % count) + count) % count; // 0..count
const u = f / count;
child.position.copy(from).addScaledVector(dir, u * len);
const mat = (child as THREE.Mesh).material as THREE.MeshBasicMaterial;
// Fade in/out near the segment ends.
mat.opacity = 0.85 * fill * Math.min(1, Math.min(u, 1 - u) * 4 + 0.15);
});
});
if (len < 0.2) return null;
return (
<group ref={groupRef}>
{Array.from({ length: count }, (_, i) => (
<mesh key={i} quaternion={quat}>
<coneGeometry args={[0.05, 0.14, 8]} />
<meshBasicMaterial color="#38bdf8" toneMapped={false} transparent opacity={0.85} />
</mesh>
))}
</group>
);
}

1264
src/components/PartBody.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,222 @@
import { useState } from 'react';
import { CATEGORIES, PART_DEFS } from '../parts/catalog';
import { HOTBAR_PARTS } from '../parts/hotbar';
import type { PartType } from '../types';
import { useBuilder } from '../store/builderStore';
import { nextQuickBuildOffset, quickBuildParts, QUICK_BUILDS } from '../utils/demoProject';
import { PartIcon } from './icons/PartIcons';
/**
* PartLibrary — left sidebar. Click a part (or drag it onto the canvas)
* to place it. Shift-click in the scene stamps multiple copies.
*/
export function PartLibrary() {
const parts = useBuilder((s) => s.parts);
const placingType = useBuilder((s) => s.placingType);
const setPlacingType = useBuilder((s) => s.setPlacingType);
const addPart = useBuilder((s) => s.addPart);
const updatePart = useBuilder((s) => s.updatePart);
const setSelected = useBuilder((s) => s.setSelected);
const toggleSelected = useBuilder((s) => s.toggleSelected);
const [search, setSearch] = useState('');
const q = search.toLowerCase();
const matchesPart = (type: PartType) => PART_DEFS[type].label.toLowerCase().includes(q);
const filteredHotbar = q ? HOTBAR_PARTS.filter(matchesPart) : HOTBAR_PARTS;
const filteredQuickBuilds = q
? QUICK_BUILDS.filter((b) => b.label.toLowerCase().includes(q))
: QUICK_BUILDS;
const filteredCategories = CATEGORIES.map((cat) => ({
...cat,
types: q ? cat.types.filter(matchesPart) : cat.types,
})).filter((cat) => cat.types.length > 0);
const addQuickBuild = (id: (typeof QUICK_BUILDS)[number]['id']) => {
setPlacingType(null);
const assembly = quickBuildParts(id, nextQuickBuildOffset(Object.values(parts)));
const ids = assembly.map((part) => {
const placedId = addPart(part.type, part.position, { snap: false });
updatePart(placedId, {
rotation: part.rotation,
params: part.params,
label: part.label,
color: part.color,
});
return placedId;
});
setSelected(ids[0] ?? null);
ids.slice(1).forEach(toggleSelected);
};
return (
<aside className="flex w-56 shrink-0 flex-col border-r border-zinc-800 bg-zinc-950/80 backdrop-blur">
<div className="border-b border-zinc-800 px-4 py-3">
<h2 className="text-xs font-semibold tracking-widest text-zinc-400 uppercase">
Parts Library
</h2>
<p className="mt-1 text-[11px] leading-snug text-zinc-600">
Click then place in scene, or drag onto the canvas. Shift-click to stamp copies.
</p>
</div>
<div className="px-3 pt-2">
<div className="relative">
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search parts…"
className="w-full rounded-md border border-zinc-800 bg-zinc-900 px-2.5 py-1.5 text-xs text-zinc-200 placeholder:text-zinc-600 outline-none focus:border-zinc-600 focus:ring-1 focus:ring-zinc-700"
/>
{search && (
<button
onClick={() => setSearch('')}
className="absolute right-1.5 top-1/2 -translate-y-1/2 rounded px-1 text-xs text-zinc-500 hover:text-zinc-300"
>
×
</button>
)}
</div>
</div>
<div className="flex-1 overflow-y-auto px-3 py-2">
{filteredHotbar.length > 0 && (
<div className="mb-4">
<h3 className="px-1 py-1.5 text-[10px] font-bold tracking-wider text-zinc-500 uppercase">
Tool Belt
</h3>
<div className="grid grid-cols-3 gap-1.5">
{filteredHotbar.map((type, idx) => (
<LibraryItem
key={`hotbar-${type}`}
type={type}
active={placingType === type}
badge={!q ? `${idx + 1}` : undefined}
compact
onClick={() => setPlacingType(placingType === type ? null : type)}
/>
))}
</div>
</div>
)}
{filteredQuickBuilds.length > 0 && (
<div className="mb-4">
<h3 className="px-1 py-1.5 text-[10px] font-bold tracking-wider text-zinc-500 uppercase">
Quick Builds
</h3>
<div className="grid gap-1.5">
{filteredQuickBuilds.map((build) => (
<QuickBuildButton
key={build.id}
label={build.label}
description={build.description}
onClick={() => addQuickBuild(build.id)}
/>
))}
</div>
</div>
)}
{filteredCategories.map((cat) => (
<div key={cat.name} className="mb-3">
<h3 className="px-1 py-1.5 text-[10px] font-bold tracking-wider text-zinc-500 uppercase">
{cat.name}
</h3>
<div className="grid grid-cols-2 gap-1.5">
{cat.types.map((t) => (
<LibraryItem
key={t}
type={t}
active={placingType === t}
onClick={() => setPlacingType(placingType === t ? null : t)}
/>
))}
</div>
</div>
))}
</div>
</aside>
);
}
function QuickBuildButton({
label,
description,
onClick,
}: {
label: string;
description: string;
onClick: () => void;
}) {
return (
<button
onClick={onClick}
title={description}
className="flex items-center justify-between gap-2 rounded-lg border border-zinc-800 bg-zinc-900/60 px-3 py-2 text-left text-zinc-200 transition hover:border-emerald-500/60 hover:bg-emerald-500/10 hover:text-emerald-100"
>
<span className="min-w-0">
<span className="block text-[12px] leading-tight font-semibold">{label}</span>
<span className="mt-0.5 block text-[10px] leading-snug text-zinc-500">
{description}
</span>
</span>
<span className="shrink-0 text-sm text-emerald-400">+</span>
</button>
);
}
function LibraryItem({
type,
active,
badge,
compact,
onClick,
}: {
type: PartType;
active: boolean;
badge?: string;
compact?: boolean;
onClick: () => void;
}) {
const def = PART_DEFS[type];
return (
<button
draggable
onDragStart={(e) => {
e.dataTransfer.setData('application/x-hydro-part', type);
e.dataTransfer.effectAllowed = 'copy';
}}
onClick={onClick}
title={
active
? `${def.description} — click again to cancel${badge ? ` (${badge})` : ''}`
: `${def.description}${badge ? ` (${badge})` : ''}`
}
className={`group relative flex flex-col items-center gap-1 rounded-lg border px-1.5 text-center transition
${
active
? 'border-sky-400 bg-sky-500/15 text-sky-300 shadow-[0_0_10px_-2px_rgba(56,189,248,0.6)] ring-1 ring-sky-400/40'
: 'border-zinc-800 bg-zinc-900/60 text-zinc-300 hover:border-zinc-600 hover:bg-zinc-800/80'
} ${compact ? 'py-1.5' : 'py-2'}`}
>
{badge && (
<span className="absolute top-1 left-1 rounded bg-zinc-950/90 px-1 py-0.5 text-[9px] font-bold leading-none text-zinc-400 ring-1 ring-zinc-800">
{badge}
</span>
)}
<PartIcon
type={type}
className={`${compact ? 'h-7 w-7' : 'h-9 w-9'} transition-transform duration-150 group-hover:scale-110 ${
active ? 'scale-110 drop-shadow-[0_0_4px_rgba(56,189,248,0.5)]' : ''
}`}
/>
<span className={`w-full font-medium break-words ${compact ? 'text-[10px]' : 'text-[11px] leading-tight'}`}>
{def.label}
</span>
{active && (
<span className="text-[9px] font-semibold tracking-wider text-sky-400 uppercase">
Placing
</span>
)}
</button>
);
}

208
src/components/PartMesh.tsx Normal file
View File

@@ -0,0 +1,208 @@
import { memo, useEffect, useMemo, useRef, useState } from 'react';
import * as THREE from 'three';
import { useCursor } from '@react-three/drei';
import type { ThreeEvent } from '@react-three/fiber';
import type { PlacedPart, Vec3 } from '../types';
import { useBuilder } from '../store/builderStore';
import { beginPartDrag } from '../utils/dragState';
import { ConnectorDots, PartBody, PartCtx } from './PartBody';
import { FLOW_PARTS } from '../parts/catalog';
interface PartMeshProps {
part: PlacedPart;
/** GPH flowing through this part (0 = idle). */
flow?: number;
selected?: boolean;
/** Render as a translucent placement preview. */
ghost?: boolean;
/** Connector keys joined to another part (for the green/amber dots). */
connectedKeys?: Set<string>;
}
/**
* Measure a part's solid geometry bounds in the part's local space.
* Only castShadow meshes count, so transient water visuals (emitter streams,
* tray fill, reservoir water) don't inflate the box.
*/
function measureLocalBox(root: THREE.Object3D): THREE.Box3 {
const box = new THREE.Box3();
root.updateWorldMatrix(true, true);
const inv = new THREE.Matrix4().copy(root.matrixWorld).invert();
const rel = new THREE.Matrix4();
const b = new THREE.Box3();
const collect = (solidOnly: boolean) => {
root.traverse((o) => {
const mesh = o as THREE.Mesh;
if (!mesh.isMesh || (solidOnly && !mesh.castShadow)) return;
if (!mesh.geometry.boundingBox) mesh.geometry.computeBoundingBox();
if (!mesh.geometry.boundingBox) return;
b.copy(mesh.geometry.boundingBox);
rel.multiplyMatrices(inv, mesh.matrixWorld);
b.applyMatrix4(rel);
box.union(b);
});
};
collect(true);
if (box.isEmpty()) collect(false);
return box;
}
interface Bounds {
center: Vec3;
size: Vec3;
}
/** Generous invisible hit size so thin pipes/valves are easy to click. */
const hitSize = (v: number) => Math.max(v + 0.22, 0.5);
/**
* PartMesh — interaction wrapper around a part's geometry (PartBody):
* selection (incl. shift multi-select & cmd-click color popover), drag start,
* hover highlight, an inflated invisible hit proxy, and connector indicators.
*/
export const PartMesh = memo(function PartMesh({
part,
flow = 0,
selected = false,
ghost = false,
connectedKeys,
}: PartMeshProps) {
const innerRef = useRef<THREE.Group>(null);
const [hovered, setHovered] = useState(false);
const [bounds, setBounds] = useState<Bounds | null>(null);
useCursor(hovered && !ghost);
// Re-measure local bounds when geometry-affecting inputs change.
const paramsKey = JSON.stringify(part.params);
useEffect(() => {
if (ghost) return;
const handle = requestAnimationFrame(() => {
const inner = innerRef.current;
if (!inner) return;
const box = measureLocalBox(inner);
if (box.isEmpty()) {
setBounds({ center: [0, 0.25, 0], size: [0.5, 0.5, 0.5] });
return;
}
const c = box.getCenter(new THREE.Vector3());
const sz = box.getSize(new THREE.Vector3());
setBounds({ center: [c.x, c.y, c.z], size: [sz.x, sz.y, sz.z] });
});
return () => cancelAnimationFrame(handle);
}, [part.type, paramsKey, ghost]);
// Soft edge outline shown on hover.
const hoverEdges = useMemo(() => {
if (!bounds || ghost) return null;
const g = new THREE.BoxGeometry(
bounds.size[0] + 0.1,
bounds.size[1] + 0.1,
bounds.size[2] + 0.1,
);
const edges = new THREE.EdgesGeometry(g);
g.dispose();
return edges;
}, [bounds, ghost]);
useEffect(() => () => hoverEdges?.dispose(), [hoverEdges]);
const onPointerDown = (e: ThreeEvent<PointerEvent>) => {
if (ghost) return;
const s = useBuilder.getState();
if (s.placingType || s.tool === 'measure') return; // ground layer handles those
// A connector handle along the same ray takes priority over the body.
const handleHit = e.intersections.find((i) => i.object.userData?.connectorHandle);
if (handleHit && handleHit.distance <= e.distance + 0.5) return;
e.stopPropagation();
const ne = e.nativeEvent;
if (ne.metaKey || ne.ctrlKey) {
// Cmd/Ctrl+click — color popover for the selection (select this part if needed).
if (!s.selectedIds.includes(part.id)) s.setSelected(part.id);
s.openColorPopover(ne.clientX, ne.clientY);
return;
}
if (ne.shiftKey) {
s.toggleSelected(part.id);
return;
}
if (!s.selectedIds.includes(part.id)) s.setSelected(part.id);
if (e.button !== 0) return;
if (ne.altKey) {
const sourceIds = s.selectedIds.includes(part.id) ? [...s.selectedIds] : [part.id];
const copiedIds = s.duplicateParts(sourceIds, { offset: [0, 0, 0] });
const sourceIndex = sourceIds.indexOf(part.id);
const dragId = copiedIds[sourceIndex] ?? copiedIds[0];
if (!dragId) return;
beginPartDrag({
partId: dragId,
grabPoint: [e.point.x, e.point.y, e.point.z],
mode: 'body',
});
return;
}
beginPartDrag({
partId: part.id,
grabPoint: [e.point.x, e.point.y, e.point.z],
mode: 'body',
});
};
const onDoubleClick = (e: ThreeEvent<MouseEvent>) => {
if (ghost) return;
e.stopPropagation();
const s = useBuilder.getState();
if (s.placingType || s.tool === 'measure') return;
if (FLOW_PARTS.has(part.type)) s.selectConnectedRun(part.id);
else s.setSelected(part.id);
if (part.type === 'pump') {
// PropertiesPanel listens and flashes/scrolls the pump quick-settings card.
window.dispatchEvent(new CustomEvent('hydro:flash-pump', { detail: part.id }));
}
};
const onPointerOver = (e: ThreeEvent<PointerEvent>) => {
if (ghost) return;
const s = useBuilder.getState();
if (s.placingType || s.tool === 'measure' || s.draggingId) return;
// No stopPropagation here (it would break r3f's move delivery during
// drags); only hover when this part is the nearest thing under the cursor.
if (e.intersections[0]?.eventObject !== e.eventObject) return;
setHovered(true);
};
return (
<PartCtx.Provider value={{ ghost, selected }}>
<group
position={part.position}
rotation={part.rotation}
onPointerDown={onPointerDown}
onDoubleClick={onDoubleClick}
onPointerOver={onPointerOver}
onPointerOut={() => setHovered(false)}
>
<group ref={innerRef}>
<group name="part-body-mesh" userData={{ partId: part.id }}>
<PartBody part={part} flow={flow} connectedKeys={connectedKeys} />
</group>
{!ghost && <ConnectorDots part={part} connectedKeys={connectedKeys} />}
</group>
{/* Inflated invisible hit proxy — makes thin parts easy to grab. */}
{!ghost && bounds && (
<mesh position={bounds.center}>
<boxGeometry
args={[hitSize(bounds.size[0]), hitSize(bounds.size[1]), hitSize(bounds.size[2])]}
/>
<meshBasicMaterial visible={false} />
</mesh>
)}
{/* Hover outline (own indicator — PartBody is untouched). */}
{!ghost && hovered && !selected && bounds && hoverEdges && (
<lineSegments geometry={hoverEdges} position={bounds.center}>
<lineBasicMaterial color="#38bdf8" transparent opacity={0.55} depthWrite={false} />
</lineSegments>
)}
</group>
</PartCtx.Provider>
);
});

View File

@@ -0,0 +1,888 @@
import { useEffect, useRef, useState, useMemo } from 'react';
import { PART_DEFS, STANDARD_PIPE_SIZES, snapPipeSize } from '../parts/catalog';
import { useBuilder } from '../store/builderStore';
import { useSimulation, computeReservoirWaterStats } from '../simulation/flowSimulator';
import { computeRoomMetrics } from '../environment/roomMetrics';
import type { PlacedPart, Vec3 } from '../types';
import { ColorSwatchRow } from './ColorPopover';
const rad2deg = (r: number) => Math.round((r * 180) / Math.PI);
const deg2rad = (d: number) => (d * Math.PI) / 180;
/**
* PropertiesPanel — right sidebar inspector.
* One part selected: full inspector (pump quick-settings first for pumps).
* Multiple parts: compact group panel (counts, color, shared actions).
*/
export function PropertiesPanel() {
const selectedIds = useBuilder((s) => s.selectedIds);
return (
<aside className="flex w-64 shrink-0 flex-col border-l border-zinc-800 bg-zinc-950/80 backdrop-blur">
<div className="border-b border-zinc-800 px-4 py-3">
<h2 className="text-xs font-semibold tracking-widest text-zinc-400 uppercase">
Inspector
{selectedIds.length > 1 && (
<span className="ml-2 rounded-full bg-sky-500/15 px-2 py-0.5 text-[10px] font-bold text-sky-300">
{selectedIds.length} selected
</span>
)}
</h2>
</div>
{selectedIds.length === 0 ? (
<EmptyState />
) : selectedIds.length === 1 ? (
<PartInspector partId={selectedIds[0]} />
) : (
<MultiInspector ids={selectedIds} />
)}
</aside>
);
}
function EmptyState() {
const growthStage = useBuilder((s) => s.growthStage);
const setGrowthStage = useBuilder((s) => s.setGrowthStage);
return (
<div className="px-4 py-6 text-xs leading-relaxed text-zinc-600 space-y-4">
{/* Global Growth Stage selector */}
<div className="rounded-lg border border-zinc-800 bg-zinc-900/30 p-3">
<h3 className="mb-1.5 text-[10px] font-bold tracking-wider text-zinc-500 uppercase">Crop Growth Stage</h3>
<select
value={growthStage}
onChange={(e) => setGrowthStage(e.target.value as any)}
className="w-full rounded-md border border-zinc-800 bg-zinc-900 px-2 py-1.5 text-xs text-zinc-200 outline-none focus:border-sky-600 cursor-pointer"
>
<option value="seedling">Seedling</option>
<option value="vegetative">Vegetative</option>
<option value="flowering">Flowering</option>
</select>
</div>
<div>
Select a part to edit its properties.
<ul className="mt-4 space-y-1.5 text-[11px] text-zinc-700">
<li><Kbd>Click</Kbd> select · drag to move</li>
<li><Kbd>Click</Kbd> add/remove from selection</li>
<li><Kbd>Click</Kbd> change color</li>
<li>Drag <span className="text-sky-600">connector dots</span> to snap pipes</li>
<li><Kbd>Drag</Kbd> move vertically (3D view)</li>
<li><Kbd>Drag</Kbd> clone while dragging</li>
<li><Kbd>Space</Kbd> play / pause water</li>
<li><Kbd>R</Kbd> rotate 90° &nbsp; <Kbd>Q</Kbd>/<Kbd>E</Kbd> lower / raise</li>
<li><Kbd>D</Kbd> duplicate &nbsp; <Kbd></Kbd> delete</li>
<li><Kbd>Z</Kbd> undo &nbsp; <Kbd>Z</Kbd> redo</li>
<li><Kbd>Esc</Kbd> deselect / cancel</li>
</ul>
</div>
</div>
);
}
function Kbd({ children }: { children: React.ReactNode }) {
return (
<kbd className="rounded border border-zinc-700 bg-zinc-800 px-1.5 py-0.5 font-mono text-[10px] text-zinc-300">
{children}
</kbd>
);
}
// ---------------------------------------------------------------------------
// Multi-selection panel
// ---------------------------------------------------------------------------
function MultiInspector({ ids }: { ids: string[] }) {
const parts = useBuilder((s) => s.parts);
const removeParts = useBuilder((s) => s.removeParts);
const duplicateParts = useBuilder((s) => s.duplicateParts);
const counts = new Map<string, number>();
for (const id of ids) {
const part = parts[id];
if (!part) continue;
const label = PART_DEFS[part.type].label;
counts.set(label, (counts.get(label) ?? 0) + 1);
}
return (
<div className="flex-1 space-y-4 overflow-y-auto px-4 py-3">
<Section title="Selection">
<ul className="space-y-1">
{[...counts.entries()].map(([label, n]) => (
<li key={label} className="flex justify-between text-xs text-zinc-300">
<span>{label}</span>
<span className="font-mono text-zinc-500">× {n}</span>
</li>
))}
</ul>
<p className="mt-2 text-[10px] leading-relaxed text-zinc-600">
Drag any selected part (or the gizmo) to move the whole group.
</p>
</Section>
<Section title="Color">
<ColorSwatchRow ids={ids} />
</Section>
<div className="flex gap-2 pt-1">
<button
onClick={() => duplicateParts(ids)}
title="Duplicate the whole selection (D)"
className="flex-1 rounded-md border border-zinc-700 bg-zinc-800 px-2 py-1.5 text-xs font-medium text-zinc-200 hover:bg-zinc-700"
>
Duplicate
</button>
<button
onClick={() => removeParts(ids)}
title="Delete every selected part (⌫)"
className="flex-1 rounded-md border border-rose-900 bg-rose-950 px-2 py-1.5 text-xs font-medium text-rose-300 hover:bg-rose-900"
>
Delete
</button>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Pump quick-settings card
// ---------------------------------------------------------------------------
/** Pump param keys surfaced in the quick card (hidden from the generic list). */
const PUMP_CARD_KEYS = new Set(['gph', 'maxHeadFt']);
const AIR_COMPRESSOR_CARD_KEYS = new Set(['gph', 'frequency']);
const RESERVOIR_CARD_KEYS = new Set(['ph', 'ec']);
function PumpCard({ part }: { part: PlacedPart }) {
const setParam = useBuilder((s) => s.setParam);
const sim = useSimulation();
const cardRef = useRef<HTMLDivElement>(null);
const [flash, setFlash] = useState(false);
// Double-clicking a pump in the scene scrolls to + flashes this card.
useEffect(() => {
const onFlash = (e: Event) => {
if ((e as CustomEvent<string>).detail !== part.id) return;
cardRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
setFlash(true);
setTimeout(() => setFlash(false), 1000);
};
window.addEventListener('hydro:flash-pump', onFlash);
return () => window.removeEventListener('hydro:flash-pump', onFlash);
}, [part.id]);
const def = PART_DEFS.pump;
const on = (part.params.on ?? 1) > 0;
const result = sim.pumps.find((p) => p.pumpId === part.id);
const gph = part.params.gph ?? def.defaults.gph;
const maxHead = part.params.maxHeadFt ?? def.defaults.maxHeadFt;
return (
<div
ref={cardRef}
className={`rounded-lg border p-3 transition-all duration-300 ${
flash
? 'border-sky-400 bg-sky-950/60 ring-2 ring-sky-400/60'
: 'border-sky-900/70 bg-sky-950/30'
}`}
>
<div className="mb-2 flex items-center justify-between">
<span className="text-[10px] font-bold tracking-wider text-sky-400 uppercase">Pump</span>
{/* Big power toggle */}
<button
onClick={() => setParam(part.id, 'on', on ? 0 : 1)}
title={on ? 'Turn pump off' : 'Turn pump on'}
className={`relative h-6 w-12 rounded-full transition-colors ${
on ? 'bg-emerald-500' : 'bg-zinc-700'
}`}
>
<span
className={`absolute top-0.5 grid h-5 w-5 place-items-center rounded-full bg-white text-[8px] font-black text-zinc-700 shadow transition-all ${
on ? 'left-6' : 'left-0.5'
}`}
>
{on ? 'I' : 'O'}
</span>
</button>
</div>
{/* Live delivered flow */}
<div className="mb-2 rounded-md bg-zinc-900/70 px-2.5 py-1.5">
<div className="text-lg font-bold tabular-nums text-sky-300">
{result && on ? result.gph.toFixed(0) : '0'}
<span className="ml-1 text-[10px] font-medium text-zinc-500">GPH delivered</span>
</div>
<div className="text-[10px] text-zinc-500 tabular-nums">
{result
? `head ${result.headFt.toFixed(1)} / ${result.maxHeadFt} ft`
: 'not connected to a reservoir'}
</div>
</div>
<QuickSlider
label="Rated flow"
unit="GPH"
value={gph}
meta={def.params.gph}
onChange={(v) => setParam(part.id, 'gph', v)}
/>
<QuickSlider
label="Max head"
unit="ft"
value={maxHead}
meta={def.params.maxHeadFt}
onChange={(v) => setParam(part.id, 'maxHeadFt', v)}
/>
</div>
);
}
function AirCompressorCard({ part }: { part: PlacedPart }) {
const setParam = useBuilder((s) => s.setParam);
const parts = useBuilder((s) => s.parts);
const cardRef = useRef<HTMLDivElement>(null);
const [flash, setFlash] = useState(false);
// Double-clicking an air compressor in the scene scrolls to + flashes this card.
useEffect(() => {
const onFlash = (e: Event) => {
if ((e as CustomEvent<string>).detail !== part.id) return;
cardRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
setFlash(true);
setTimeout(() => setFlash(false), 1000);
};
window.addEventListener('hydro:flash-airCompressor', onFlash);
return () => window.removeEventListener('hydro:flash-airCompressor', onFlash);
}, [part.id]);
const def = PART_DEFS.airCompressor;
const on = (part.params.on ?? 1) > 0;
const gph = part.params.gph ?? def.defaults.gph;
const frequency = part.params.frequency ?? def.defaults.frequency;
// Find nearest reservoir
const reservoir = useMemo(() => {
let nearest: PlacedPart | null = null;
let minDist = Infinity;
const pos = part.position;
for (const p of Object.values(parts)) {
if (p.type === 'reservoir') {
const d = Math.hypot(p.position[0] - pos[0], p.position[1] - pos[1], p.position[2] - pos[2]);
if (d < minDist) {
minDist = d;
nearest = p;
}
}
}
return nearest;
}, [parts, part.position]);
// Compute stats if we have a reservoir
const stats = useMemo(() => {
if (!reservoir) return null;
// Find all air compressors targeting this reservoir
let totalAerationGph = 0;
for (const p of Object.values(parts)) {
if (p.type === 'airCompressor' && (p.params.on ?? 1) > 0) {
// Find nearest reservoir for this compressor
let compNearest: PlacedPart | null = null;
let cMinDist = Infinity;
for (const res of Object.values(parts)) {
if (res.type === 'reservoir') {
const d = Math.hypot(res.position[0] - p.position[0], res.position[1] - p.position[1], res.position[2] - p.position[2]);
if (d < cMinDist) {
cMinDist = d;
compNearest = res;
}
}
}
if (compNearest && compNearest.id === reservoir.id) {
totalAerationGph += p.params.gph ?? 120;
}
}
}
const w = reservoir.params.width ?? 2;
const d = reservoir.params.depth ?? 1.4;
const h = reservoir.params.height ?? 1;
const level = reservoir.params.level ?? 70;
const volGal = w * d * h * (level / 100) * 7.48;
// TDO Calculation
let tdo = 5.5;
if (totalAerationGph > 0 && volGal > 0) {
const ratio = totalAerationGph / volGal;
tdo = 5.5 + 3.7 * (1 - Math.exp(-ratio * 2.0));
}
return {
volGal,
totalAerationGph,
tdo,
};
}, [parts, reservoir]);
return (
<div
ref={cardRef}
className={`rounded-lg border p-3 transition-all duration-300 ${
flash
? 'border-sky-400 bg-sky-950/60 ring-2 ring-sky-400/60'
: 'border-sky-900/70 bg-sky-950/30'
}`}
>
<div className="mb-2 flex items-center justify-between">
<span className="text-[10px] font-bold tracking-wider text-sky-400 uppercase">Air Compressor</span>
<button
onClick={() => setParam(part.id, 'on', on ? 0 : 1)}
title={on ? 'Turn compressor off' : 'Turn compressor on'}
className={`relative h-6 w-12 rounded-full transition-colors ${
on ? 'bg-emerald-500' : 'bg-zinc-700'
}`}
>
<span
className={`absolute top-0.5 grid h-5 w-5 place-items-center rounded-full bg-white text-[8px] font-black text-zinc-700 shadow transition-all ${
on ? 'left-6' : 'left-0.5'
}`}
>
{on ? 'I' : 'O'}
</span>
</button>
</div>
{/* Target and Live Dissolved Oxygen */}
<div className="mb-3 rounded-md bg-zinc-900/70 px-2.5 py-1.5 space-y-1">
<div className="text-[10px] font-semibold text-zinc-400">Target Reservoir:</div>
{reservoir ? (
<div>
<div className="flex justify-between items-center text-xs text-zinc-200">
<span className="font-medium">{reservoir.label || `Reservoir #${reservoir.id}`}</span>
<span className="text-[10px] font-mono text-zinc-500">
{Math.hypot(reservoir.position[0] - part.position[0], reservoir.position[2] - part.position[2]).toFixed(1)} ft away
</span>
</div>
{stats && (
<div className="mt-1.5 pt-1.5 border-t border-zinc-800/60 space-y-1.5">
<div className="flex justify-between items-center">
<span className="text-[10px] text-zinc-500">Dissolved O:</span>
<span
className={`rounded px-1.5 py-0.5 text-[10px] font-bold ${
stats.tdo >= 8.0
? 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/20'
: stats.tdo >= 7.0
? 'bg-sky-500/10 text-sky-400 border border-sky-500/20'
: 'bg-amber-500/10 text-amber-400 border border-amber-500/20'
}`}
>
{stats.tdo.toFixed(1)} mg/L
</span>
</div>
<div className="flex justify-between text-[9px] text-zinc-500">
<span>Tank Volume:</span>
<span className="font-mono text-zinc-300">{stats.volGal.toFixed(1)} Gal</span>
</div>
<div className="flex justify-between text-[9px] text-zinc-500">
<span>Total Aeration:</span>
<span className="font-mono text-zinc-300">{stats.totalAerationGph} GPH</span>
</div>
</div>
)}
</div>
) : (
<div className="text-xs text-amber-400/80 font-medium">No reservoir in scene!</div>
)}
</div>
<QuickSlider
label="Air Flow"
unit="GPH"
value={gph}
meta={def.params.gph}
onChange={(v) => setParam(part.id, 'gph', v)}
/>
<QuickSlider
label="Bubble Frequency"
unit="Hz"
value={frequency}
meta={def.params.frequency}
onChange={(v) => setParam(part.id, 'frequency', v)}
/>
</div>
);
}
function GrowTentCard({ part }: { part: PlacedPart }) {
const parts = useBuilder((s) => s.parts);
const metrics = useMemo(() => {
const allMetrics = computeRoomMetrics(parts);
return allMetrics.find((m) => m.roomId === part.id);
}, [parts, part.id]);
if (!metrics) return null;
return (
<div className="rounded-lg border border-sky-900/50 bg-sky-950/20 p-3 space-y-3">
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold tracking-wider text-sky-400 uppercase">Room Simulation</span>
<span className="font-mono text-xs text-sky-300">{metrics.volumeFt3.toFixed(0)} ft³</span>
</div>
<div className="grid grid-cols-3 gap-2">
{/* VPD */}
<div className="rounded-md bg-zinc-900/80 p-2 text-center">
<div className="text-[9px] text-zinc-500 font-bold uppercase">VPD</div>
<div className="mt-0.5 text-xs font-bold text-emerald-400">
{metrics.roomVPD.toFixed(2)}
<span className="text-[8px] font-normal text-zinc-500 ml-0.5">kPa</span>
</div>
</div>
{/* DLI */}
<div className="rounded-md bg-zinc-900/80 p-2 text-center">
<div className="text-[9px] text-zinc-500 font-bold uppercase">DLI</div>
<div className="mt-0.5 text-xs font-bold text-amber-400">
{metrics.roomDLI.toFixed(1)}
</div>
</div>
{/* CO2 */}
<div className="rounded-md bg-zinc-900/80 p-2 text-center">
<div className="text-[9px] text-zinc-500 font-bold uppercase">CO</div>
<div className="mt-0.5 text-xs font-bold text-sky-400">
{metrics.roomCo2}
<span className="text-[8px] font-normal text-zinc-500 ml-0.5">PPM</span>
</div>
</div>
</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>Est. Temp:</span>
<span className="text-zinc-200">{metrics.roomTempF.toFixed(1)}°F</span>
</div>
<div className="flex justify-between">
<span>Est. RH:</span>
<span className="text-zinc-200">{metrics.roomRH}%</span>
</div>
<div className="flex justify-between">
<span>Room PPFD:</span>
<span className="text-zinc-200">{metrics.roomPPFD.toFixed(0)} μmol/m²/s</span>
</div>
<div className="flex justify-between">
<span>AC Sizing:</span>
<span className="text-zinc-300 font-semibold">{Math.round(metrics.acSizingBtuHr)} BTU/hr</span>
</div>
<div className="flex justify-between">
<span>Dehumidifier:</span>
<span className="text-zinc-300 font-semibold">{metrics.dehumidifierPintsDay.toFixed(1)} Pints/day</span>
</div>
<div className="flex justify-between">
<span>Sensible Heat:</span>
<span className="text-zinc-500">{Math.round(metrics.sensibleHeatGainsBtuHr)} BTU/hr</span>
</div>
<div className="flex justify-between">
<span>Latent Cooling:</span>
<span className="text-emerald-500/80">{Math.round(metrics.latentCoolingBtuHr)} BTU/hr</span>
</div>
</div>
</div>
);
}
function ReservoirCard({ part }: { part: PlacedPart }) {
const setParam = useBuilder((s) => s.setParam);
const parts = useBuilder((s) => s.parts);
const growthStage = useBuilder((s) => s.growthStage);
const sim = useSimulation();
const stats = useMemo(() => computeReservoirWaterStats(part, parts), [part, parts]);
const runtime = sim.reservoirRuntime[part.id];
const { ecLow, ecHigh } = useMemo(() => {
if (growthStage === 'seedling') return { ecLow: 0.4, ecHigh: 0.8 };
if (growthStage === 'flowering') return { ecLow: 1.5, ecHigh: 2.2 };
return { ecLow: 1.0, ecHigh: 1.6 }; // vegetative
}, [growthStage]);
const isEcAlert = stats.ec < ecLow || stats.ec > ecHigh;
return (
<div className="rounded-lg border border-sky-900/50 bg-sky-950/20 p-3 space-y-3">
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold tracking-wider text-sky-400 uppercase">Reservoir Health</span>
<span className={`font-mono text-xs ${runtime?.empty ? 'text-rose-300' : 'text-sky-300'}`}>
{(runtime?.gallonsCurrent ?? stats.volGal).toFixed(1)} Gal
</span>
</div>
<div className="grid grid-cols-2 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'}`}>
{runtime
? runtime.minutesRemaining === null
? 'Stable'
: runtime.empty
? 'Dry'
: `${runtime.minutesRemaining.toFixed(1)} min`
: 'Stable'}
</div>
<div className="text-[8px] text-zinc-600">
{runtime?.minutesRemaining === null
? 'No net draw'
: runtime?.empty
? 'Source exhausted'
: `${runtime.netOutflowGph.toFixed(1)} GPH net draw`}
</div>
</div>
<div className="rounded-md bg-zinc-900/80 p-2 text-center">
<div className="text-[9px] text-zinc-500 font-bold uppercase">Volume</div>
<div className="mt-0.5 text-xs font-bold text-sky-300">
{runtime
? `${runtime.gallonsCurrent.toFixed(1)} / ${runtime.gallonsCapacity.toFixed(1)}`
: `${stats.volGal.toFixed(1)} / ${stats.volGal.toFixed(1)}`}
</div>
<div className="text-[8px] text-zinc-600">Gallons</div>
</div>
</div>
{/* Water Stats Grid */}
<div className="grid grid-cols-3 gap-2">
{/* Temp */}
<div className="rounded-md bg-zinc-900/80 p-2 text-center">
<div className="text-[9px] text-zinc-500 font-bold uppercase">Temp</div>
<div
className={`mt-0.5 text-xs font-bold ${
stats.tempF > 73.0 ? 'text-amber-400' : 'text-emerald-400'
}`}
>
{stats.tempF.toFixed(1)}°F
</div>
<div className="text-[8px] text-zinc-600">
{stats.tempF > 73.0 ? 'Too Warm' : 'Optimal'}
</div>
</div>
{/* pH */}
<div className="rounded-md bg-zinc-900/80 p-2 text-center">
<div className="text-[9px] text-zinc-500 font-bold uppercase">pH</div>
<div
className={`mt-0.5 text-xs font-bold ${
stats.ph < 5.5 || stats.ph > 6.5 ? 'text-amber-400' : 'text-emerald-400'
}`}
>
{stats.ph.toFixed(1)}
</div>
<div className="text-[8px] text-zinc-600">
{stats.ph < 5.5 || stats.ph > 6.5 ? 'Lockout' : 'Optimal'}
</div>
</div>
{/* EC */}
<div className="rounded-md bg-zinc-900/80 p-2 text-center">
<div className="text-[9px] text-zinc-500 font-bold uppercase">EC</div>
<div
className={`mt-0.5 text-xs font-bold ${
isEcAlert ? 'text-amber-400' : 'text-emerald-400'
}`}
>
{stats.ec.toFixed(1)}
</div>
<div className="text-[8px] text-zinc-600">
{isEcAlert ? 'Alert' : 'Optimal'}
</div>
</div>
</div>
<QuickSlider
label="Adjust pH"
unit=""
value={part.params.ph ?? 6.0}
meta={PART_DEFS.reservoir.params.ph}
onChange={(v) => setParam(part.id, 'ph', v)}
/>
<QuickSlider
label="Adjust EC"
unit="mS/cm"
value={part.params.ec ?? 1.2}
meta={PART_DEFS.reservoir.params.ec}
onChange={(v) => setParam(part.id, 'ec', v)}
/>
</div>
);
}
function QuickSlider({
label,
unit,
value,
meta,
onChange,
}: {
label: string;
unit: string;
value: number;
meta: { min: number; max: number; step: number };
onChange: (v: number) => void;
}) {
return (
<div className="mt-1.5">
<div className="mb-0.5 flex justify-between text-[11px]">
<span className="text-zinc-400">{label}</span>
<span className="font-mono text-sky-400">
{value} {unit}
</span>
</div>
<input
type="range"
min={meta.min}
max={meta.max}
step={meta.step}
value={value}
onChange={(e) => onChange(parseFloat(e.target.value))}
className="w-full accent-sky-500"
/>
</div>
);
}
// ---------------------------------------------------------------------------
// Single-part inspector
// ---------------------------------------------------------------------------
function PartInspector({ partId }: { partId: string }) {
const part = useBuilder((s) => s.parts[partId]);
const parts = useBuilder((s) => s.parts);
const updatePart = useBuilder((s) => s.updatePart);
const setParam = useBuilder((s) => s.setParam);
const removeParts = useBuilder((s) => s.removeParts);
const duplicateParts = useBuilder((s) => s.duplicateParts);
const rotateParts = useBuilder((s) => s.rotateParts);
const pushHistory = useBuilder((s) => s.pushHistory);
const sim = useSimulation();
if (!part) return null;
const def = PART_DEFS[part.type];
const netPotStatus = part.type === 'netPot' ? sim.netPots.find((p) => p.partId === partId) : undefined;
const sourcePart = netPotStatus ? parts[netPotStatus.sourcePartId] : undefined;
const setPos = (axis: number, v: number) => {
pushHistory(`pos:${partId}:${axis}`);
const position = [...part.position] as Vec3;
position[axis] = axis === 1 ? Math.max(0, v) : v;
updatePart(partId, { position });
};
const setRot = (axis: number, degVal: number) => {
pushHistory(`rot:${partId}:${axis}`);
const rotation = [...part.rotation] as Vec3;
rotation[axis] = deg2rad(degVal);
updatePart(partId, { rotation });
};
const paramEntries = Object.entries(def.params).filter(
([key]) =>
(part.type !== 'pump' || !PUMP_CARD_KEYS.has(key)) &&
(part.type !== 'airCompressor' || !AIR_COMPRESSOR_CARD_KEYS.has(key)) &&
(part.type !== 'reservoir' || !RESERVOIR_CARD_KEYS.has(key)),
);
return (
<div className="flex-1 space-y-4 overflow-y-auto px-4 py-3">
<div>
<div className="mb-1 flex items-center gap-2">
<span
className="h-3 w-3 rounded"
style={{ backgroundColor: part.color ?? def.color }}
/>
<span className="text-sm font-semibold text-zinc-200">{def.label}</span>
<span className="ml-auto font-mono text-[10px] text-zinc-600">#{part.id}</span>
</div>
<input
value={part.label ?? ''}
placeholder="Name this part…"
onChange={(e) => updatePart(partId, { label: e.target.value })}
className="w-full rounded-md border border-zinc-800 bg-zinc-900 px-2 py-1.5 text-xs text-zinc-200 placeholder-zinc-600 outline-none focus:border-sky-600"
/>
</div>
{part.type === 'pump' && <PumpCard part={part} />}
{part.type === 'airCompressor' && <AirCompressorCard part={part} />}
{part.type === 'reservoir' && <ReservoirCard part={part} />}
{part.type === 'growTent' && <GrowTentCard part={part} />}
{part.type === 'netPot' && netPotStatus && (
<Section title="Hydraulics">
<div
className={`rounded-lg border px-3 py-2.5 ${
netPotStatus.overflowing
? 'border-rose-900/70 bg-rose-950/40'
: 'border-amber-900/60 bg-amber-950/30'
}`}
>
<div
className={`text-xs font-semibold ${
netPotStatus.overflowing ? 'text-rose-300' : 'text-amber-300'
}`}
>
{netPotStatus.overflowing ? 'Pot overflow active' : 'Pot near overflow'}
</div>
<p className="mt-1 text-[11px] leading-snug text-zinc-400">
Water under this pot is coming from {sourcePart ? PART_DEFS[sourcePart.type].label : 'the flooded line'} at{' '}
{netPotStatus.inflowGph.toFixed(0)} GPH. Water height is {netPotStatus.waterHeightFt.toFixed(2)} ft and the rim is {netPotStatus.rimHeightFt.toFixed(2)} ft.
</p>
<p className="mt-1.5 rounded-md bg-zinc-900/80 px-2 py-1 text-[11px] leading-snug text-zinc-300">
<span className="font-semibold text-sky-400">Fix: </span>
Add drainage or move the pot off the backed-up run.
</p>
</div>
</Section>
)}
<Section title="Position (ft)">
<div className="grid grid-cols-3 gap-1.5">
{(['X', 'Y', 'Z'] as const).map((axis, i) => (
<NumberField
key={axis}
label={axis}
value={part.position[i]}
step={0.25}
onChange={(v) => setPos(i, v)}
/>
))}
</div>
</Section>
<Section title="Rotation (°)">
<div className="grid grid-cols-3 gap-1.5">
{(['X', 'Y', 'Z'] as const).map((axis, i) => (
<NumberField
key={axis}
label={axis}
value={rad2deg(part.rotation[i])}
step={15}
onChange={(v) => setRot(i, v)}
/>
))}
</div>
<div className="mt-1.5 flex gap-1.5">
<MiniBtn onClick={() => rotateParts([partId], 1, Math.PI / 2)}> 90° Y</MiniBtn>
<MiniBtn onClick={() => rotateParts([partId], 2, Math.PI / 2)}> 90° Z</MiniBtn>
<MiniBtn onClick={() => updatePart(partId, { rotation: [0, 0, 0] })}>Reset</MiniBtn>
</div>
</Section>
{paramEntries.length > 0 && (
<Section title="Parameters">
<div className="space-y-2.5">
{paramEntries.map(([key, meta]) => (
<div key={key}>
<div className="mb-0.5 flex justify-between text-[11px]">
<span className="text-zinc-400">{meta.label}</span>
<span className="font-mono text-sky-400">
{part.params[key] ?? def.defaults[key]}
{meta.unit ? ` ${meta.unit}` : ''}
</span>
</div>
{key === 'diameter' || key === 'diameterA' || key === 'diameterB' ? (
<select
value={String(snapPipeSize(part.params[key] ?? def.defaults[key]))}
onChange={(e) => setParam(partId, key, parseFloat(e.target.value))}
className="w-full rounded-md border border-zinc-800 bg-zinc-900 px-2 py-1.5 text-[11px] text-zinc-200 outline-none focus:border-sky-600"
>
{STANDARD_PIPE_SIZES.map((size) => (
<option key={size} value={size}>
{size} in
</option>
))}
</select>
) : (
<input
type="range"
min={meta.min}
max={meta.max}
step={meta.step}
value={part.params[key] ?? def.defaults[key]}
onChange={(e) => setParam(partId, key, parseFloat(e.target.value))}
className="w-full accent-sky-500"
/>
)}
</div>
))}
</div>
</Section>
)}
<Section title="Color">
<ColorSwatchRow ids={[partId]} />
</Section>
<div className="flex gap-2 pt-1">
<button
onClick={() => duplicateParts([partId])}
title="Duplicate this part (D)"
className="flex-1 rounded-md border border-zinc-700 bg-zinc-800 px-2 py-1.5 text-xs font-medium text-zinc-200 hover:bg-zinc-700"
>
Duplicate
</button>
<button
onClick={() => removeParts([partId])}
title="Delete this part (⌫)"
className="flex-1 rounded-md border border-rose-900 bg-rose-950 px-2 py-1.5 text-xs font-medium text-rose-300 hover:bg-rose-900"
>
Delete
</button>
</div>
</div>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div>
<h3 className="mb-1.5 text-[10px] font-bold tracking-wider text-zinc-500 uppercase">
{title}
</h3>
{children}
</div>
);
}
function NumberField({
label,
value,
step,
onChange,
}: {
label: string;
value: number;
step: number;
onChange: (v: number) => void;
}) {
return (
<label className="flex items-center gap-1 rounded-md border border-zinc-800 bg-zinc-900 px-1.5">
<span className="text-[10px] font-bold text-zinc-600">{label}</span>
<input
type="number"
value={Number(value.toFixed(2))}
step={step}
onChange={(e) => {
const v = parseFloat(e.target.value);
if (!Number.isNaN(v)) onChange(v);
}}
className="w-full bg-transparent py-1.5 text-right font-mono text-[11px] text-zinc-200 outline-none"
/>
</label>
);
}
function MiniBtn({ children, onClick }: { children: React.ReactNode; onClick: () => void }) {
return (
<button
onClick={onClick}
className="flex-1 rounded border border-zinc-800 bg-zinc-900 px-1.5 py-1 text-[10px] text-zinc-300 hover:bg-zinc-800"
>
{children}
</button>
);
}

View File

@@ -0,0 +1,996 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Canvas, useThree, useFrame } from '@react-three/fiber';
import type { ThreeEvent } from '@react-three/fiber';
import {
Grid,
Html,
Line,
OrbitControls,
OrthographicCamera,
PerspectiveCamera,
GizmoHelper,
GizmoViewport,
} from '@react-three/drei';
import * as THREE from 'three';
import type { PartType, PlacedPart, Vec3, ViewMode } from '../types';
import { snapValue, useBuilder } from '../store/builderStore';
import { PART_DEFS } from '../parts/catalog';
import { useSimulation } from '../simulation/flowSimulator';
import { PartMesh } from './PartMesh';
import { FlowArrows } from './FlowArrows';
import { ConnectorHandles } from './ConnectorHandles';
import { SelectionGizmo } from './SelectionGizmo';
import { dragCtx } from '../utils/dragState';
import {
findNearestOpenConnector,
findNearestPipeTap,
getWorldConnectors,
} from '../utils/connectors';
import { AirCompressorVisuals } from './waterVisuals';
const HALF_PI = Math.PI / 2;
/** Default placement height per part type (pipes float at port height). */
function placementY(type: PartType): number {
switch (type) {
case 'pipe':
case 'elbow':
case 'tee':
case 'valve':
case 'emitter':
return 0.25;
default:
return 0;
}
}
/** Exposed so the HTML5 drag-and-drop handler in App can place parts. */
export const sceneRefs: { camera: THREE.Camera | null; el: HTMLElement | null } = {
camera: null,
el: null,
};
/** Raycast client coords onto the ground plane (used by sidebar drag-drop). */
export function clientToGround(clientX: number, clientY: number): Vec3 | null {
const { camera, el } = sceneRefs;
if (!camera || !el) return null;
const rect = el.getBoundingClientRect();
const ndc = new THREE.Vector2(
((clientX - rect.left) / rect.width) * 2 - 1,
-((clientY - rect.top) / rect.height) * 2 + 1,
);
const ray = new THREE.Raycaster();
ray.setFromCamera(ndc, camera);
const hit = new THREE.Vector3();
if (ray.ray.intersectPlane(new THREE.Plane(new THREE.Vector3(0, 1, 0), 0), hit)) {
return [hit.x, hit.y, hit.z];
}
return null;
}
function CaptureRefs() {
const camera = useThree((s) => s.camera);
const el = useThree((s) => s.gl.domElement);
const scene = useThree((s) => s.scene);
const setScene = useBuilder((s) => s.setScene);
useEffect(() => {
sceneRefs.camera = camera;
sceneRefs.el = el;
}, [camera, el]);
useEffect(() => {
setScene(scene);
return () => setScene(null);
}, [scene, setScene]);
return null;
}
// ---------------------------------------------------------------------------
// Camera presets
// ---------------------------------------------------------------------------
const VIEWS: Record<ViewMode, { pos: Vec3; up: Vec3; target: Vec3 }> = {
orbit: { pos: [9, 7, 11], up: [0, 1, 0], target: [0, 1, 0] },
top: { pos: [0, 60, 0], up: [0, 0, -1], target: [0, 0, 0] },
front: { pos: [0, 2, 60], up: [0, 1, 0], target: [0, 2, 0] },
side: { pos: [60, 2, 0], up: [0, 1, 0], target: [0, 2, 0] },
};
function sceneStyle(renderMode: 'builder' | 'xray' | 'water') {
switch (renderMode) {
case 'xray':
return {
background: '#071019',
fogFar: 180,
ambient: 0.72,
hemi: 0.82,
dir: 1.15,
gridCell: '#15384a',
gridSection: '#1f536d',
};
case 'water':
return {
background: '#04131c',
fogFar: 210,
ambient: 0.3,
hemi: 0.42,
dir: 0.9,
gridCell: '#0c2a3b',
gridSection: '#134764',
};
default:
return {
background: '#0a0e13',
fogFar: 140,
ambient: 0.45,
hemi: 0.5,
dir: 1.4,
gridCell: '#1b2531',
gridSection: '#2c3d52',
};
}
}
function CameraRig() {
const viewMode = useBuilder((s) => s.viewMode);
const camera = useThree((s) => s.camera);
const controls = useThree((s) => s.controls) as unknown as {
target: THREE.Vector3;
update: () => void;
} | null;
useEffect(() => {
const v = VIEWS[viewMode];
camera.position.set(...v.pos);
camera.up.set(...v.up);
camera.lookAt(...v.target);
if (controls) {
controls.target.set(...v.target);
controls.update();
}
}, [viewMode, camera, controls]);
return null;
}
// ---------------------------------------------------------------------------
// Ground interaction: placement ghost, measuring, deselection
// ---------------------------------------------------------------------------
function InteractionLayer() {
const placingType = useBuilder((s) => s.placingType);
const tool = useBuilder((s) => s.tool);
const snapToGrid = useBuilder((s) => s.snapToGrid);
const gridSize = useBuilder((s) => s.gridSize);
const measurePoints = useBuilder((s) => s.measurePoints);
const pipeRunPoints = useBuilder((s) => s.pipeRunPoints);
const parts = useBuilder((s) => s.parts);
const activeRoomId = useBuilder((s) => s.activeRoomId);
const [ghostPos, setGhostPos] = useState<Vec3 | null>(null);
const [pipeRunHover, setPipeRunHover] = useState<Vec3 | null>(null);
const roomParts = useMemo(() => {
return Object.values(parts).filter((p) => p.roomId === activeRoomId);
}, [parts, activeRoomId]);
const snap = (v: number) => (snapToGrid ? snapValue(v, gridSize) : v);
const toPos = (e: ThreeEvent<PointerEvent | MouseEvent>): Vec3 => [
snap(e.point.x),
placingType ? placementY(placingType) : 0,
snap(e.point.z),
];
const onMove = (e: ThreeEvent<PointerEvent>) => {
if (placingType) setGhostPos(toPos(e));
if (tool === 'pipeRun') {
const pos = toPos(e);
const target = findNearestOpenConnector(pos, roomParts, new Set(), SNAP_PIPE_RUN_DIST);
if (target) {
useBuilder.getState().setSnapTargetKey(target.key);
setPipeRunHover(target.pos);
} else {
const tap = findNearestPipeTap(pos, roomParts, SNAP_PIPE_RUN_DIST);
useBuilder.getState().setSnapTargetKey(null);
setPipeRunHover(tap ? tap.branchPos : pos);
}
} else if (useBuilder.getState().snapTargetKey) {
useBuilder.getState().setSnapTargetKey(null);
}
};
const onClick = (e: ThreeEvent<MouseEvent>) => {
if (e.delta > 6) return; // it was a camera drag, not a click
const s = useBuilder.getState();
if (placingType) {
s.addPart(placingType, toPos(e));
// Hold Shift to stamp multiple copies.
if (!e.nativeEvent.shiftKey) s.setPlacingType(null);
return;
}
if (tool === 'measure') {
s.addMeasurePoint([e.point.x, Math.max(0, e.point.y), e.point.z]);
return;
}
if (tool === 'pipeRun') {
const pos = toPos(e);
const target = findNearestOpenConnector(pos, roomParts, new Set(), SNAP_PIPE_RUN_DIST);
if (target) {
s.setSnapTargetKey(target.key);
s.extendPipeRun(
{ pos: target.pos, connectorKey: target.key },
e.nativeEvent.shiftKey,
);
} else {
const tap = findNearestPipeTap(pos, roomParts, SNAP_PIPE_RUN_DIST);
s.setSnapTargetKey(null);
s.extendPipeRun(
tap
? {
pos: tap.pos,
tapPipeId: tap.partId,
tapRotationY: tap.rotationY,
tapDiameter: tap.diameter,
}
: { pos },
e.nativeEvent.shiftKey,
);
}
if (!e.nativeEvent.shiftKey) s.setSnapTargetKey(null);
return;
}
// Only deselect when the ground itself was the nearest thing clicked —
// clicks that land on a part/handle first must not clear the selection.
if (e.intersections[0]?.eventObject !== e.eventObject) return;
// Shift-clicking empty ground keeps the multi-selection intact.
if (!e.nativeEvent.shiftKey) s.setSelected(null);
};
const ghostPart: PlacedPart | null = useMemo(() => {
if (!placingType || !ghostPos) return null;
return {
id: '__ghost__',
type: placingType,
position: ghostPos,
rotation: [0, 0, 0],
params: { ...PART_DEFS[placingType].defaults },
};
}, [placingType, ghostPos]);
return (
<>
<mesh
rotation={[-HALF_PI, 0, 0]}
position={[0, -0.02, 0]}
onPointerMove={onMove}
onClick={onClick}
>
<planeGeometry args={[600, 600]} />
<meshBasicMaterial visible={false} side={THREE.DoubleSide} />
</mesh>
{/* Soft shadow catcher */}
<mesh rotation={[-HALF_PI, 0, 0]} position={[0, -0.01, 0]} receiveShadow>
<planeGeometry args={[300, 300]} />
<shadowMaterial opacity={0.35} />
</mesh>
{ghostPart && <PartMesh part={ghostPart} ghost />}
{measurePoints.length > 0 && <MeasureViz points={measurePoints} />}
{tool === 'pipeRun' && pipeRunPoints.length > 0 && pipeRunHover && (
<PipeRunViz points={pipeRunPoints} hover={pipeRunHover} />
)}
</>
);
}
function PipeRunViz({
points,
hover,
}: {
points: { pos: Vec3; connectorKey?: string }[];
hover: Vec3;
}) {
const start = points[points.length - 1].pos;
const end = hover;
const lineStart: Vec3 = [start[0], 0.28, start[2]];
const lineEnd: Vec3 = [end[0], 0.28, end[2]];
const mid: Vec3 = [(lineStart[0] + lineEnd[0]) / 2, 0.55, (lineStart[2] + lineEnd[2]) / 2];
const dist = Math.hypot(end[0] - start[0], end[2] - start[2]);
const clamped = clampPipeLength(dist);
return (
<group>
{points.length > 1 && (
<Line
points={points.map((p) => [p.pos[0], 0.26, p.pos[2]] as Vec3)}
color="#0ea5e9"
lineWidth={2}
dashed
dashScale={3}
/>
)}
<Line points={[lineStart, lineEnd]} color="#38bdf8" lineWidth={2} dashed dashScale={4} />
<Html position={mid} center distanceFactor={12}>
<div className="rounded bg-sky-400 px-1.5 py-0.5 font-mono text-[11px] font-bold text-sky-950 whitespace-nowrap">
{clamped.toFixed(2)} ft · Shift-click continues
</div>
</Html>
</group>
);
}
function MeasureViz({ points }: { points: Vec3[] }) {
const [a, b] = points;
const mid: Vec3 | null = b
? [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2 + 0.3, (a[2] + b[2]) / 2]
: null;
const dist = b ? Math.hypot(b[0] - a[0], b[1] - a[1], b[2] - a[2]) : 0;
return (
<group>
{points.map((p, i) => (
<mesh key={i} position={p}>
<sphereGeometry args={[0.09, 12, 12]} />
<meshBasicMaterial color="#fbbf24" toneMapped={false} />
</mesh>
))}
{b && (
<>
<Line points={[a, b]} color="#fbbf24" lineWidth={2} dashed dashScale={3} />
<Html position={mid!} center distanceFactor={12}>
<div className="rounded bg-amber-400 px-1.5 py-0.5 font-mono text-[11px] font-bold text-black whitespace-nowrap">
{dist.toFixed(2)} ft
</div>
</Html>
</>
)}
</group>
);
}
// ---------------------------------------------------------------------------
// Drag plane — moves the dragged part (and the rest of the selection,
// rigidly), oriented per view. In orbit view, holding Shift switches to a
// camera-facing plane so parts can be moved vertically. Connector-handle
// drags additionally live-highlight + magnetically snap to mating connectors.
// ---------------------------------------------------------------------------
/** Within this distance (ft) a handle drag magnetically sticks to the target. */
const MAGNET_DIST = 0.45;
const SNAP_PIPE_RUN_DIST = 0.9;
const MIN_PIPE_LENGTH = PART_DEFS.pipe.params.length.min;
const MAX_PIPE_LENGTH = PART_DEFS.pipe.params.length.max;
function clampPipeLength(length: number) {
return Math.min(MAX_PIPE_LENGTH, Math.max(MIN_PIPE_LENGTH, length));
}
function stretchPipeToPoint(point: Vec3): { end: Vec3; position: Vec3; length: number } | null {
const stretch = dragCtx.pipeStretch;
if (!stretch) return null;
const toPoint: Vec3 = [
point[0] - stretch.anchorPos[0],
point[1] - stretch.anchorPos[1],
point[2] - stretch.anchorPos[2],
];
const projected =
toPoint[0] * stretch.axis[0] + toPoint[1] * stretch.axis[1] + toPoint[2] * stretch.axis[2];
const length = clampPipeLength(projected);
const end: Vec3 = [
stretch.anchorPos[0] + stretch.axis[0] * length,
stretch.anchorPos[1] + stretch.axis[1] * length,
stretch.anchorPos[2] + stretch.axis[2] * length,
];
return {
end,
position: [
(stretch.anchorPos[0] + end[0]) / 2,
(stretch.anchorPos[1] + end[1]) / 2,
(stretch.anchorPos[2] + end[2]) / 2,
],
length,
};
}
function DragPlane() {
const draggingId = useBuilder((s) => s.draggingId);
const viewMode = useBuilder((s) => s.viewMode);
const part = useBuilder((s) => (s.draggingId ? s.parts[s.draggingId] : undefined));
const camera = useThree((s) => s.camera);
const [shiftDown, setShiftDown] = useState(false);
// Track Shift (vertical-move modifier in orbit view) even before a drag starts.
useEffect(() => {
const down = (e: KeyboardEvent) => e.key === 'Shift' && setShiftDown(true);
const up = (e: KeyboardEvent) => e.key === 'Shift' && setShiftDown(false);
window.addEventListener('keydown', down);
window.addEventListener('keyup', up);
return () => {
window.removeEventListener('keydown', down);
window.removeEventListener('keyup', up);
};
}, []);
// Safety: end the drag even if pointerup happens off-canvas.
useEffect(() => {
if (!draggingId) return;
const end = () => finishDrag(draggingId);
window.addEventListener('pointerup', end);
return () => window.removeEventListener('pointerup', end);
}, [draggingId]);
if (!draggingId || !part) return null;
const freeMode = viewMode === 'orbit' && shiftDown;
const planeKey = `${viewMode}:${freeMode ? 'free' : 'flat'}`;
const group = dragCtx.groupIds.length ? dragCtx.groupIds : [draggingId];
const onMove = (e: ThreeEvent<PointerEvent>) => {
const s = useBuilder.getState();
const p = s.parts[draggingId];
if (!p) return;
if (!dragCtx.pushed) {
s.pushHistory(`drag:${draggingId}`);
dragCtx.pushed = true;
}
// The plane orientation changed mid-drag (Shift toggled): re-anchor so the
// part doesn't jump under the new plane.
if (dragCtx.planeKey && dragCtx.planeKey !== planeKey) {
dragCtx.grabOffset = [
e.point.x - p.position[0],
e.point.y - p.position[1],
e.point.z - p.position[2],
];
dragCtx.planeAnchor = [e.point.x, e.point.y, e.point.z];
}
dragCtx.planeKey = planeKey;
const snap = (v: number) => (s.snapToGrid ? snapValue(v, s.gridSize) : v);
if (dragCtx.pipeStretch && dragCtx.connectorKey) {
let handlePoint: Vec3;
if (freeMode) {
handlePoint = [snap(e.point.x), snap(e.point.y), snap(e.point.z)];
} else if (viewMode === 'front') {
handlePoint = [snap(e.point.x), snap(e.point.y), dragCtx.planeAnchor[2]];
} else if (viewMode === 'side') {
handlePoint = [dragCtx.planeAnchor[0], snap(e.point.y), snap(e.point.z)];
} else {
handlePoint = [snap(e.point.x), dragCtx.planeAnchor[1], snap(e.point.z)];
}
let stretch = stretchPipeToPoint(handlePoint);
if (!stretch) return;
const snapTarget = findNearestOpenConnector(
stretch.end,
Object.values(s.parts).filter((p) => p.roomId === s.activeRoomId),
new Set([draggingId]),
);
s.setSnapTargetKey(snapTarget ? snapTarget.key : null);
if (snapTarget && snapTarget.d < MAGNET_DIST) {
stretch = stretchPipeToPoint(snapTarget.pos) ?? stretch;
}
s.setPipeStretch(draggingId, stretch.position, stretch.length);
return;
}
const [ox, oy, oz] = dragCtx.grabOffset;
const startP = dragCtx.startPositions[draggingId] ?? p.position;
let target: Vec3;
if (freeMode) {
target = [snap(e.point.x - ox), snap(e.point.y - oy), snap(e.point.z - oz)];
} else if (viewMode === 'front') {
target = [snap(e.point.x - ox), snap(e.point.y - oy), startP[2]];
} else if (viewMode === 'side') {
target = [startP[0], snap(e.point.y - oy), snap(e.point.z - oz)];
} else {
target = [snap(e.point.x - ox), startP[1], snap(e.point.z - oz)];
}
let delta: Vec3 = [target[0] - startP[0], target[1] - startP[1], target[2] - startP[2]];
// Handle drags: highlight the nearest open mating connector and stick to
// it magnetically when close.
if (dragCtx.mode === 'handle' && dragCtx.connectorKey) {
const connectorId = dragCtx.connectorKey.split(':')[1];
const moved: PlacedPart = {
...p,
position: [startP[0] + delta[0], startP[1] + delta[1], startP[2] + delta[2]],
};
const conn = getWorldConnectors(moved).find((c) => c.connectorId === connectorId);
if (conn) {
const snapTarget = findNearestOpenConnector(
conn.pos,
Object.values(s.parts).filter((part) => part.roomId === s.activeRoomId),
new Set(group),
);
s.setSnapTargetKey(snapTarget ? snapTarget.key : null);
if (snapTarget && snapTarget.d < MAGNET_DIST) {
delta = [
delta[0] + snapTarget.pos[0] - conn.pos[0],
delta[1] + snapTarget.pos[1] - conn.pos[1],
delta[2] + snapTarget.pos[2] - conn.pos[2],
];
}
}
}
// Keep the whole group above the floor.
let minY = Infinity;
for (const id of group) {
const sp = dragCtx.startPositions[id];
if (sp) minY = Math.min(minY, sp[1]);
}
if (Number.isFinite(minY)) delta[1] = Math.max(delta[1], -minY);
const updates: Record<string, Vec3> = {};
for (const id of group) {
const sp = dragCtx.startPositions[id];
if (!sp) continue;
updates[id] = [sp[0] + delta[0], sp[1] + delta[1], sp[2] + delta[2]];
}
s.setPositions(updates);
};
const anchor = dragCtx.planeAnchor;
const planeProps = {
onPointerMove: onMove,
onPointerUp: () => finishDrag(draggingId),
};
const planeContents = (
<>
<planeGeometry args={[600, 600]} />
<meshBasicMaterial visible={false} side={THREE.DoubleSide} />
</>
);
if (freeMode) {
// Camera-facing plane through the grab point: free vertical + lateral moves.
return (
<mesh position={anchor} quaternion={camera.quaternion} {...planeProps}>
{planeContents}
</mesh>
);
}
const vertical = viewMode === 'front' || viewMode === 'side';
return (
<mesh
position={
vertical
? viewMode === 'front'
? [0, 0, anchor[2]]
: [anchor[0], 0, 0]
: [0, anchor[1], 0]
}
rotation={
vertical ? (viewMode === 'side' ? [0, HALF_PI, 0] : [0, 0, 0]) : [-HALF_PI, 0, 0]
}
{...planeProps}
>
{planeContents}
</mesh>
);
}
function finishDrag(id: string) {
const s = useBuilder.getState();
if (s.draggingId !== id) return;
const group = dragCtx.groupIds.length ? dragCtx.groupIds : [id];
// A plain click (no movement) on a member of a multi-selection collapses
// the selection to just that part — dragging keeps the group together.
const start = dragCtx.startPositions[id];
const p = s.parts[id];
const moved =
!start || !p
? false
: Math.hypot(
p.position[0] - start[0],
p.position[1] - start[1],
p.position[2] - start[2],
) > 1e-6;
if (dragCtx.mode === 'body' && !moved && group.length > 1) {
s.setSelected(id);
s.setSnapTargetKey(null);
s.setDraggingId(null);
return;
}
if (dragCtx.mode === 'handle' && dragCtx.connectorKey && s.snapTargetKey) {
// Snap the grabbed connector exactly onto the highlighted target.
const p = s.parts[id];
const targetPart = s.parts[s.snapTargetKey.split(':')[0]];
if (p && targetPart) {
const connectorId = dragCtx.connectorKey.split(':')[1];
const conn = getWorldConnectors(p).find((c) => c.connectorId === connectorId);
const target = getWorldConnectors(targetPart).find((c) => c.key === s.snapTargetKey);
if (conn && target) {
if (dragCtx.pipeStretch) {
const stretch = stretchPipeToPoint(target.pos);
if (stretch) s.setPipeStretch(id, stretch.position, stretch.length);
} else {
s.translateParts(group, [
target.pos[0] - conn.pos[0],
target.pos[1] - conn.pos[1],
target.pos[2] - conn.pos[2],
]);
}
}
}
} else if (dragCtx.mode === 'body') {
// Magnetic connector snapping on drop (vs. parts outside the group).
s.snapGroupToConnectors(group, id);
}
s.setSnapTargetKey(null);
s.setDraggingId(null);
}
// ---------------------------------------------------------------------------
// Parts + simulation glue
// ---------------------------------------------------------------------------
function PartMeshes() {
const parts = useBuilder((s) => s.parts);
const activeRoomId = useBuilder((s) => s.activeRoomId);
const selectedIds = useBuilder((s) => s.selectedIds);
const sim = useSimulation();
const roomParts = useMemo(() => {
return Object.values(parts).filter((p) => p.roomId === activeRoomId);
}, [parts, activeRoomId]);
return (
<>
{roomParts.map((part) => (
<PartMesh
key={part.id}
part={part}
flow={sim.flows[part.id] ?? 0}
selected={selectedIds.includes(part.id)}
connectedKeys={sim.connectedKeys}
/>
))}
</>
);
}
// ---------------------------------------------------------------------------
// Alignment guides — thin dashed lines between the dragged part and aligned
// siblings (X or Z within 0.05 ft tolerance). Only the 20 nearest parts are
// checked each frame to keep things fast.
// ---------------------------------------------------------------------------
const ALIGN_TOL = 0.05;
const ALIGN_MAX_NEIGHBOURS = 20;
const ALIGN_Y = 0.03; // just above the grid
function AlignmentGuides() {
const draggingId = useBuilder((s) => s.draggingId);
const parts = useBuilder((s) => s.parts);
const activeRoomId = useBuilder((s) => s.activeRoomId);
const guides = useMemo(() => {
if (!draggingId) return [];
const dragged = parts[draggingId];
if (!dragged) return [];
const [dx, , dz] = dragged.position;
const others = Object.values(parts).filter((p) => p.id !== draggingId && p.roomId === activeRoomId);
// Sort by distance and take the nearest N.
const nearest = others
.map((p) => ({ p, d: Math.hypot(p.position[0] - dx, p.position[2] - dz) }))
.sort((a, b) => a.d - b.d)
.slice(0, ALIGN_MAX_NEIGHBOURS)
.map((e) => e.p);
const lines: { points: [Vec3, Vec3]; key: string }[] = [];
for (const other of nearest) {
const [ox, , oz] = other.position;
// X alignment
if (Math.abs(ox - dx) < ALIGN_TOL) {
const minZ = Math.min(dz, oz);
const maxZ = Math.max(dz, oz);
lines.push({
points: [
[ox, ALIGN_Y, minZ],
[ox, ALIGN_Y, maxZ],
],
key: `x:${other.id}`,
});
}
// Z alignment
if (Math.abs(oz - dz) < ALIGN_TOL) {
const minX = Math.min(dx, ox);
const maxX = Math.max(dx, ox);
lines.push({
points: [
[minX, ALIGN_Y, oz],
[maxX, ALIGN_Y, oz],
],
key: `z:${other.id}`,
});
}
}
return lines;
}, [draggingId, parts]);
if (guides.length === 0) return null;
return (
<group>
{guides.map((g) => (
<Line
key={g.key}
points={g.points}
color="#22d3ee"
lineWidth={1.5}
dashed
dashScale={6}
/>
))}
</group>
);
}
// ---------------------------------------------------------------------------
// Camera focus — smoothly lerps the orbit controls target toward a store-
// provided focusTarget, then clears it once arrived (~0.5s).
// ---------------------------------------------------------------------------
function CameraFocus() {
const focusTarget = useBuilder((s) => s.focusTarget);
const controls = useThree((s) => s.controls) as unknown as {
target: THREE.Vector3;
update: () => void;
} | null;
const camera = useThree((s) => s.camera);
const progressRef = useRef(0);
const startTargetRef = useRef(new THREE.Vector3());
const startCamRef = useRef(new THREE.Vector3());
const goalRef = useRef<Vec3 | null>(null);
// When a new focusTarget arrives, snapshot the current target and start lerping.
useEffect(() => {
if (!focusTarget || !controls) return;
startTargetRef.current.copy(controls.target);
startCamRef.current.copy(camera.position);
goalRef.current = focusTarget;
progressRef.current = 0;
}, [focusTarget, controls, camera]);
useFrame((_, delta) => {
const goal = goalRef.current;
if (!goal || !controls) return;
progressRef.current = Math.min(1, progressRef.current + delta / 0.5);
const t = progressRef.current;
// Smooth-step easing
const ease = t * t * (3 - 2 * t);
controls.target.lerpVectors(
startTargetRef.current,
new THREE.Vector3(...goal),
ease,
);
// Also shift the camera by the same delta so framing is preserved.
const offset = camera.position.clone().sub(startCamRef.current);
const targetDelta = new THREE.Vector3(...goal).sub(startTargetRef.current).multiplyScalar(ease);
camera.position.copy(startCamRef.current).add(offset).add(targetDelta);
controls.update();
if (t >= 1) {
goalRef.current = null;
useBuilder.getState().setFocusTarget(null);
}
});
return null;
}
// ---------------------------------------------------------------------------
// Camera keyboard panning
// ---------------------------------------------------------------------------
function KeyboardNavigator() {
const camera = useThree((s) => s.camera);
const controls = useThree((s) => s.controls) as unknown as {
target: THREE.Vector3;
update: () => void;
} | null;
const activeKeys = useRef<Record<string, boolean>>({});
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
const target = e.target as HTMLElement;
if (target && /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName)) return;
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
e.preventDefault();
activeKeys.current[e.key] = true;
}
};
const onKeyUp = (e: KeyboardEvent) => {
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
activeKeys.current[e.key] = false;
}
};
window.addEventListener('keydown', onKeyDown, { passive: false });
window.addEventListener('keyup', onKeyUp);
return () => {
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('keyup', onKeyUp);
};
}, []);
useFrame((_, delta) => {
const d = Math.min(0.1, delta); // clamp to prevent jumps on frames skip
const right = new THREE.Vector3(1, 0, 0).applyQuaternion(camera.quaternion);
const up = new THREE.Vector3(0, 1, 0).applyQuaternion(camera.quaternion);
const moveVec = new THREE.Vector3();
if (activeKeys.current['ArrowUp']) moveVec.add(up);
if (activeKeys.current['ArrowDown']) moveVec.add(up.clone().negate());
if (activeKeys.current['ArrowLeft']) moveVec.add(right.clone().negate());
if (activeKeys.current['ArrowRight']) moveVec.add(right);
if (moveVec.lengthSq() > 0) {
moveVec.normalize();
const speed = 15; // ft/sec panning speed
moveVec.multiplyScalar(speed * d);
camera.position.add(moveVec);
if (controls) {
controls.target.add(moveVec);
}
}
// Always clamp coordinates to keep building grid within ±120 ft
camera.position.x = Math.max(-120, Math.min(120, camera.position.x));
camera.position.y = Math.max(-120, Math.min(120, camera.position.y));
camera.position.z = Math.max(-120, Math.min(120, camera.position.z));
if (controls) {
controls.target.x = Math.max(-120, Math.min(120, controls.target.x));
controls.target.y = Math.max(-120, Math.min(120, controls.target.y));
controls.target.z = Math.max(-120, Math.min(120, controls.target.z));
controls.update();
}
});
return null;
}
// ---------------------------------------------------------------------------
// Root canvas
// ---------------------------------------------------------------------------
function RemoteCursors({ remoteCursors }: { remoteCursors: Record<string, any> }) {
const activeRoomId = useBuilder((s) => s.activeRoomId);
return (
<>
{Object.entries(remoteCursors).map(([userId, data]) => {
if (data.activeRoomId !== activeRoomId) return null;
return (
<mesh key={userId} position={data.position}>
<sphereGeometry args={[0.15, 16, 16]} />
<meshBasicMaterial color={data.color} toneMapped={false} />
<Html distanceFactor={15} center position={[0, 0.4, 0]}>
<div
className="px-1.5 py-0.5 rounded text-[10px] font-bold text-white whitespace-nowrap shadow-lg select-none pointer-events-none"
style={{ backgroundColor: data.color }}
>
{data.userName.split('@')[0]}
</div>
</Html>
</mesh>
);
})}
</>
);
}
function LocalCursorTracker() {
const { raycaster } = useThree();
useFrame(() => {
const plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -0.25);
const target = new THREE.Vector3();
if (raycaster.ray.intersectPlane(plane, target)) {
window.dispatchEvent(
new CustomEvent('collab:cursor', {
detail: { x: target.x, y: target.y, z: target.z },
})
);
}
});
return null;
}
export function SceneCanvas({ remoteCursors = {} }: { remoteCursors?: Record<string, any> }) {
const viewMode = useBuilder((s) => s.viewMode);
const renderMode = useBuilder((s) => s.renderMode);
const draggingId = useBuilder((s) => s.draggingId);
const gizmoDragging = useBuilder((s) => s.gizmoDragging);
const orbitRef = useRef(null);
const style = sceneStyle(renderMode);
return (
<Canvas shadows dpr={[1, 2]} className="touch-none">
<color attach="background" args={[style.background]} />
<fog attach="fog" args={[style.background, 50, style.fogFar]} />
{viewMode === 'orbit' ? (
<PerspectiveCamera makeDefault fov={50} position={VIEWS.orbit.pos} near={0.1} far={400} />
) : (
<OrthographicCamera
makeDefault
zoom={48}
position={VIEWS[viewMode].pos}
near={0.1}
far={400}
/>
)}
<OrbitControls
ref={orbitRef}
makeDefault
enabled={!draggingId && !gizmoDragging}
enableRotate={viewMode === 'orbit'}
enableDamping
dampingFactor={0.12}
maxPolarAngle={Math.PI / 2 - 0.02}
minDistance={2}
maxDistance={120}
/>
<CameraRig />
<CaptureRefs />
<KeyboardNavigator />
<ambientLight intensity={style.ambient} />
<hemisphereLight args={['#bcd6ff', '#1a2230', style.hemi]} />
<directionalLight
position={[12, 18, 8]}
intensity={style.dir}
castShadow
shadow-mapSize={[2048, 2048]}
shadow-camera-left={-25}
shadow-camera-right={25}
shadow-camera-top={25}
shadow-camera-bottom={-25}
/>
<Grid
position={[0, 0, 0]}
args={[200, 200]}
cellSize={0.5}
cellColor={style.gridCell}
sectionSize={2.5}
sectionColor={style.gridSection}
fadeDistance={90}
fadeStrength={1.5}
infiniteGrid
/>
<InteractionLayer />
<DragPlane />
<PartMeshes />
<ConnectorHandles />
<SelectionGizmo />
<FlowArrows />
<AirCompressorVisuals />
<AlignmentGuides />
<CameraFocus />
<RemoteCursors remoteCursors={remoteCursors} />
<LocalCursorTracker />
<GizmoHelper alignment="top-right" margin={[80, 80]}>
<GizmoViewport axisColors={['#ef4444', '#22c55e', '#3b82f6']} labelColor="white" />
</GizmoHelper>
</Canvas>
);
}

View File

@@ -0,0 +1,99 @@
import { useLayoutEffect, useMemo, useRef } from 'react';
import * as THREE from 'three';
import { PivotControls } from '@react-three/drei';
import type { Vec3 } from '../types';
import { snapValue, useBuilder } from '../store/builderStore';
/**
* SelectionGizmo — a translate-only pivot gizmo at the selection centroid.
* Dragging an axis arrow / plane slider moves every selected part rigidly in
* all three dimensions (grid-snapped, floor-clamped), as one undo step.
*/
export function SelectionGizmo() {
const selectedIds = useBuilder((s) => s.selectedIds);
const parts = useBuilder((s) => s.parts);
const draggingId = useBuilder((s) => s.draggingId);
const placingType = useBuilder((s) => s.placingType);
const tool = useBuilder((s) => s.tool);
const matrix = useMemo(() => new THREE.Matrix4(), []);
const draggingRef = useRef(false);
const startRef = useRef<{ centroid: Vec3; positions: Record<string, Vec3> } | null>(null);
const selected = selectedIds.map((id) => parts[id]).filter(Boolean);
const centroid: Vec3 = [0, 0, 0];
for (const p of selected) {
centroid[0] += p.position[0] / selected.length;
centroid[1] += p.position[1] / selected.length;
centroid[2] += p.position[2] / selected.length;
}
// Keep the gizmo parked at the selection centroid whenever it isn't the
// thing doing the moving (selection changes, undo, inspector edits, ...).
useLayoutEffect(() => {
if (!draggingRef.current) matrix.setPosition(centroid[0], centroid[1], centroid[2]);
});
if (!selected.length || draggingId || placingType || tool === 'measure') return null;
const onDragStart = () => {
draggingRef.current = true;
const s = useBuilder.getState();
s.pushHistory();
s.setGizmoDragging(true);
const positions: Record<string, Vec3> = {};
for (const id of s.selectedIds) {
const p = s.parts[id];
if (p) positions[id] = [...p.position] as Vec3;
}
startRef.current = { centroid: [...centroid] as Vec3, positions };
};
const onDrag = (l: THREE.Matrix4) => {
const start = startRef.current;
if (!start) return;
const s = useBuilder.getState();
matrix.copy(l); // gizmo follows the pointer
const v = new THREE.Vector3().setFromMatrixPosition(l);
const snap = (n: number) => (s.snapToGrid ? snapValue(n, s.gridSize) : n);
const delta: Vec3 = [
snap(v.x - start.centroid[0]),
snap(v.y - start.centroid[1]),
snap(v.z - start.centroid[2]),
];
// Don't let any part of the group sink below the floor.
let minY = Infinity;
for (const p of Object.values(start.positions)) minY = Math.min(minY, p[1]);
if (Number.isFinite(minY)) delta[1] = Math.max(delta[1], -minY);
const updates: Record<string, Vec3> = {};
for (const [id, sp] of Object.entries(start.positions)) {
updates[id] = [sp[0] + delta[0], sp[1] + delta[1], sp[2] + delta[2]];
}
s.setPositions(updates);
};
const onDragEnd = () => {
draggingRef.current = false;
startRef.current = null;
useBuilder.getState().setGizmoDragging(false);
};
return (
<PivotControls
matrix={matrix}
autoTransform={false}
fixed
scale={72}
lineWidth={3}
depthTest={false}
disableRotations
disableScaling
axisColors={['#f87171', '#4ade80', '#60a5fa']}
hoveredColor="#fbbf24"
onDragStart={onDragStart}
onDrag={onDrag}
onDragEnd={onDragEnd}
/>
);
}

View File

@@ -0,0 +1,303 @@
import { useMemo } from 'react';
import { useSimulation, computeReservoirWaterStats } from '../simulation/flowSimulator';
import { useBuilder } from '../store/builderStore';
import { computeRoomMetrics } from '../environment/roomMetrics';
import type { WarningLevel } from '../types';
const LEVEL_STYLE: Record<WarningLevel, string> = {
error: 'border-rose-900/60 bg-rose-950/60 text-rose-300',
warning: 'border-amber-900/60 bg-amber-950/50 text-amber-300',
info: 'border-sky-900/60 bg-sky-950/50 text-sky-300',
};
const LEVEL_ICON: Record<WarningLevel, string> = {
error: '⛔',
warning: '⚠️',
info: '',
};
/**
* StatusPanel — bottom bar showing live simulation results:
* per-pump delivered flow, system totals, and diagnostics.
*/
export function StatusPanel() {
const sim = useSimulation();
const partCount = useBuilder((s) => Object.keys(s.parts).length);
const parts = useBuilder((s) => s.parts);
const setSelected = useBuilder((s) => s.setSelected);
const simRunning = useBuilder((s) => s.simRunning);
const toggleSimRunning = useBuilder((s) => s.toggleSimRunning);
const heatmapMode = useBuilder((s) => s.heatmapMode);
const toggleHeatmapMode = useBuilder((s) => s.toggleHeatmapMode);
const roomMetrics = useMemo(() => computeRoomMetrics(parts), [parts]);
const reservoirs = useMemo(() => {
return Object.values(parts).filter((p) => p.type === 'reservoir');
}, [parts]);
const reservoirStats = useMemo(() => {
return reservoirs.map((res) => computeReservoirWaterStats(res, parts));
}, [reservoirs, parts]);
const lph = sim.totalGph * 3.785;
const flowing = sim.pumps.some((p) => p.running);
const peakVelocity = Math.max(0, ...Object.values(sim.velocities));
const peakPressure = Math.max(0, ...Object.values(sim.pressures));
return (
<footer className="flex h-36 shrink-0 border-t border-zinc-800 bg-zinc-950/90 backdrop-blur">
{/* System stats */}
<div className="flex w-56 shrink-0 flex-col justify-center gap-1 border-r border-zinc-800 px-4">
<h3 className="text-[10px] font-bold tracking-wider text-zinc-500 uppercase">
Simulation
</h3>
<div className="text-2xl font-bold text-sky-400 tabular-nums">
{sim.totalGph.toFixed(0)}
<span className="ml-1 text-xs font-medium text-zinc-500">GPH</span>
</div>
<div className="text-[11px] text-zinc-500 tabular-nums">
{lph.toFixed(0)} L/h · {partCount} parts · {sim.connectionCount} connections
</div>
<div className="text-[10px] text-zinc-600 tabular-nums">
peak {peakVelocity.toFixed(1)} ft/s · {peakPressure.toFixed(1)} psi
</div>
<div className="flex items-center gap-2 mt-1">
<button
onClick={toggleSimRunning}
title={simRunning ? 'Pause simulation (Space)' : 'Resume simulation (Space)'}
className={`inline-flex w-fit cursor-pointer items-center gap-1.5 rounded-full px-2 py-0.5 text-[10px] font-semibold transition hover:brightness-125 ${
!simRunning
? 'bg-amber-950 text-amber-400 ring-1 ring-amber-800'
: flowing
? 'bg-emerald-950 text-emerald-400 ring-1 ring-emerald-800'
: 'bg-zinc-900 text-zinc-500 ring-1 ring-zinc-800'
}`}
>
<span className="relative flex h-1.5 w-1.5">
{simRunning && flowing && (
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-60" />
)}
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-current" />
</span>
{!simRunning ? '⏸ Paused' : flowing ? 'Water flowing' : 'System idle'}
</button>
<label className="inline-flex cursor-pointer items-center gap-1 text-[10px] font-semibold text-zinc-400 hover:text-zinc-200">
<input
type="checkbox"
checked={heatmapMode}
onChange={toggleHeatmapMode}
className="accent-sky-500 cursor-pointer h-3 w-3 rounded border-zinc-700 bg-zinc-850"
/>
<span>Heatmap</span>
</label>
</div>
</div>
{/* Pumps */}
<div className="flex w-72 shrink-0 flex-col gap-1.5 overflow-y-auto border-r border-zinc-800 px-4 py-2.5">
<h3 className="text-[10px] font-bold tracking-wider text-zinc-500 uppercase">Pumps</h3>
{sim.pumps.length === 0 && (
<p className="text-xs text-zinc-600">No pumps in the system.</p>
)}
{sim.pumps.map((p) => (
<button
key={p.pumpId}
onClick={() => setSelected(p.pumpId)}
className="rounded-md border border-zinc-800 bg-zinc-900/70 px-2.5 py-1.5 text-left hover:border-zinc-600"
>
<div className="flex items-baseline justify-between">
<span className="text-xs font-semibold text-zinc-200">
{p.gph > 0 ? `${p.gph.toFixed(0)} GPH` : 'No flow'}
<span className="ml-1 text-[10px] font-normal text-zinc-500">
/ {p.ratedGph} rated
</span>
</span>
<span className="font-mono text-[10px] text-zinc-500">
head {p.headFt.toFixed(1)}/{p.maxHeadFt} ft
</span>
</div>
{/* Efficiency bar */}
<div className="mt-1 h-1 overflow-hidden rounded-full bg-zinc-800">
<div
className={`h-full rounded-full ${p.gph / p.ratedGph > 0.5 ? 'bg-emerald-500' : p.gph > 0 ? 'bg-amber-500' : 'bg-rose-600'}`}
style={{ width: `${Math.max(2, (p.gph / p.ratedGph) * 100)}%` }}
/>
</div>
</button>
))}
</div>
{/* Diagnostics */}
<div className="flex w-80 shrink-0 flex-col gap-1 overflow-y-auto border-r border-zinc-800 px-4 py-2.5">
<h3 className="text-[10px] font-bold tracking-wider text-zinc-500 uppercase">
Environment
</h3>
{roomMetrics.length === 0 ? (
<p className="text-xs text-zinc-600">Add a grow tent/room to track air exchange.</p>
) : (
roomMetrics.map((room) => (
<button
key={room.roomId}
onClick={() => setSelected(room.roomId)}
className="rounded-md border border-zinc-800 bg-zinc-900/70 px-2.5 py-1.5 text-left hover:border-zinc-600"
>
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-semibold text-zinc-200">{room.label}</span>
<span
className={`rounded-full px-2 py-0.5 text-[10px] font-semibold ${
room.sealed
? 'bg-emerald-950 text-emerald-300 ring-1 ring-emerald-800'
: 'bg-sky-950 text-sky-300 ring-1 ring-sky-800'
}`}
>
{room.sealed ? 'Sealed' : 'Open air'}
</span>
</div>
<div className="mt-1 text-[11px] text-zinc-400 tabular-nums">
{room.volumeFt3.toFixed(0)} ft3 · {room.exhaustCfm.toFixed(0)} exhaust CFM
</div>
<div className="text-[10px] text-zinc-500 tabular-nums">
{room.airChangesPerMinute.toFixed(2)} exchanges/min · {room.airChangesPerHour.toFixed(1)} ACH
</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'}
</div>
<div className="mt-1 flex gap-1.5 text-[9px] font-mono">
<span className="rounded bg-zinc-900 px-1 py-0.5 text-emerald-400 border border-zinc-800">
VPD {room.roomVPD.toFixed(2)} kPa
</span>
<span className="rounded bg-zinc-900 px-1 py-0.5 text-amber-400 border border-zinc-800">
DLI {room.roomDLI.toFixed(1)}
</span>
<span className="rounded bg-zinc-900 px-1 py-0.5 text-sky-400 border border-zinc-800">
CO {room.roomCo2} ppm
</span>
</div>
</button>
))
)}
</div>
{/* Room Readout */}
<div className="flex w-56 shrink-0 flex-col justify-center gap-1.5 border-r border-zinc-800 px-4 py-2.5">
<h3 className="text-[10px] font-bold tracking-wider text-zinc-500 uppercase">
Room Readout
</h3>
{roomMetrics.length === 0 ? (
<p className="text-xs text-zinc-600">No room placed</p>
) : (() => {
const m = roomMetrics[0];
return (
<div className="flex flex-wrap gap-1 text-[10px] font-mono tabular-nums">
<span className="rounded bg-zinc-900 px-1.5 py-0.5 border border-zinc-800 text-rose-400">
🌡 {m.roomTempF.toFixed(0)}°F
</span>
<span className="rounded bg-zinc-900 px-1.5 py-0.5 border border-zinc-800 text-sky-400">
💧 {m.roomRH.toFixed(0)}%
</span>
<span className="rounded bg-zinc-900 px-1.5 py-0.5 border border-zinc-800 text-emerald-400">
VPD {m.roomVPD.toFixed(2)}
</span>
<span className="rounded bg-zinc-900 px-1.5 py-0.5 border border-zinc-800 text-sky-400">
CO {m.roomCo2}
</span>
<span className="rounded bg-zinc-900 px-1.5 py-0.5 border border-zinc-800 text-amber-400">
{m.roomPPFD.toFixed(0)}
</span>
</div>
);
})()}
</div>
{/* Aeration & Water */}
<div className="flex w-72 shrink-0 flex-col gap-1.5 overflow-y-auto border-r border-zinc-800 px-4 py-2.5">
<h3 className="text-[10px] font-bold tracking-wider text-zinc-500 uppercase">
Aeration & Water
</h3>
{reservoirStats.length === 0 && (
<p className="text-xs text-zinc-600">No reservoirs in the system.</p>
)}
{reservoirStats.map((stats) => (
<button
key={stats.id}
onClick={() => setSelected(stats.id)}
className="rounded-md border border-zinc-800 bg-zinc-900/70 px-2.5 py-1.5 text-left hover:border-zinc-600"
>
{(() => {
const runtime = sim.reservoirRuntime[stats.id];
return runtime ? (
<div className="mb-1 flex items-center justify-between text-[9px] font-mono">
<span className={`${runtime.empty ? 'text-rose-400' : 'text-sky-400'}`}>
{runtime.gallonsCurrent.toFixed(1)} / {runtime.gallonsCapacity.toFixed(1)} gal
</span>
<span className="text-zinc-500">
{runtime.minutesRemaining === null
? 'steady'
: `${runtime.minutesRemaining.toFixed(1)} min left`}
</span>
</div>
) : null;
})()}
<div className="flex items-baseline justify-between">
<span className="text-xs font-semibold text-zinc-200">
{stats.label}
</span>
<span className="font-mono text-[10px] text-zinc-500">
{stats.volGal.toFixed(1)} Gal
</span>
</div>
<div className="mt-1 flex items-center justify-between text-[10px] text-zinc-400">
<span>Aeration: {stats.activeAerationGph} GPH</span>
<span
className={`rounded px-1.5 py-0.5 text-[9px] font-bold ${
stats.tdo >= 8.0
? 'bg-emerald-950 text-emerald-400 border border-emerald-800/30'
: stats.tdo >= 7.0
? 'bg-sky-950 text-sky-300 border border-sky-800/30'
: 'bg-amber-950 text-amber-400 border border-amber-800/30 animate-pulse'
}`}
>
{stats.tdo.toFixed(1)} mg/L
</span>
</div>
<div className="mt-1.5 flex justify-between text-[9px] text-zinc-500 font-mono">
<span className={stats.tempF > 73.0 ? 'text-amber-400 font-semibold' : 'text-zinc-500'}>
Temp: {stats.tempF.toFixed(1)}°F
</span>
<span className={stats.ph < 5.5 || stats.ph > 6.5 ? 'text-amber-400 font-semibold' : 'text-zinc-500'}>
pH: {stats.ph.toFixed(1)}
</span>
<span className={stats.ec < 0.8 || stats.ec > 2.0 ? 'text-amber-400 font-semibold' : 'text-zinc-500'}>
EC: {stats.ec.toFixed(1)}
</span>
</div>
</button>
))}
</div>
<div className="flex flex-1 flex-col gap-1 overflow-y-auto px-4 py-2.5">
<h3 className="text-[10px] font-bold tracking-wider text-zinc-500 uppercase">
Diagnostics
</h3>
{sim.warnings.length === 0 ? (
<p className="flex items-center gap-1.5 text-xs text-emerald-400">
System healthy no issues detected.
</p>
) : (
sim.warnings.map((w, i) => (
<button
key={i}
onClick={() => w.partId && setSelected(w.partId)}
className={`rounded-md border px-2.5 py-1 text-left text-[11px] leading-snug ${LEVEL_STYLE[w.level]} ${
w.partId ? 'cursor-pointer hover:brightness-125' : 'cursor-default'
}`}
>
{LEVEL_ICON[w.level]} {w.message}
</button>
))
)}
</div>
</footer>
);
}

93
src/components/Toast.tsx Normal file
View File

@@ -0,0 +1,93 @@
import { useEffect } from 'react';
import { create } from 'zustand';
/* ------------------------------------------------------------------ */
/* Toast store */
/* ------------------------------------------------------------------ */
export type ToastType = 'success' | 'error' | 'info';
interface Toast {
id: string;
message: string;
type: ToastType;
/** Set to true when the toast is being dismissed (triggers fade-out). */
exiting?: boolean;
}
interface ToastStore {
toasts: Toast[];
addToast: (message: string, type: ToastType) => void;
dismissToast: (id: string) => void;
}
export const useToastStore = create<ToastStore>((set, get) => ({
toasts: [],
addToast: (message, type) => {
const id = typeof crypto.randomUUID === 'function'
? crypto.randomUUID().slice(0, 8)
: Math.random().toString(36).substring(2, 10);
set({ toasts: [...get().toasts, { id, message, type }] });
// Auto-remove after 3 s (with a 300 ms exit animation lead-in)
setTimeout(() => get().dismissToast(id), 3000);
},
dismissToast: (id) => {
// Mark as exiting so CSS can animate out
set({ toasts: get().toasts.map((t) => (t.id === id ? { ...t, exiting: true } : t)) });
// Remove from DOM after the fade-out animation
setTimeout(() => set({ toasts: get().toasts.filter((t) => t.id !== id) }), 300);
},
}));
/** Convenience shortcut so callers don't need to import the store. */
export const addToast = useToastStore.getState().addToast;
/* ------------------------------------------------------------------ */
/* Border color map */
/* ------------------------------------------------------------------ */
const BORDER: Record<ToastType, string> = {
success: 'border-l-emerald-500',
error: 'border-l-rose-500',
info: 'border-l-sky-500',
};
/* ------------------------------------------------------------------ */
/* ToastContainer */
/* ------------------------------------------------------------------ */
export function ToastContainer() {
const toasts = useToastStore((s) => s.toasts);
const dismiss = useToastStore((s) => s.dismissToast);
if (toasts.length === 0) return null;
return (
<div className="pointer-events-none fixed bottom-4 right-4 z-[200] flex flex-col-reverse gap-2">
{toasts.map((t) => (
<ToastItem key={t.id} toast={t} onDismiss={dismiss} />
))}
</div>
);
}
function ToastItem({ toast, onDismiss }: { toast: Toast; onDismiss: (id: string) => void }) {
return (
<div
className={`pointer-events-auto flex items-start gap-2 rounded-lg border-l-4 bg-zinc-900 px-4 py-3 shadow-2xl ${BORDER[toast.type]} ${
toast.exiting ? 'animate-toast-out' : 'animate-toast-in'
}`}
>
<span className="text-sm text-zinc-200">{toast.message}</span>
<button
onClick={() => onDismiss(toast.id)}
className="ml-2 shrink-0 text-zinc-500 hover:text-zinc-200 transition cursor-pointer text-xs leading-none mt-0.5"
>
</button>
</div>
);
}

555
src/components/Toolbar.tsx Normal file
View File

@@ -0,0 +1,555 @@
import { useRef, useState, useEffect } from 'react';
import { useBuilder } from '../store/builderStore';
import type { RenderMode, ViewMode, PlacedPart } from '../types';
import {
downloadProject,
importProjectFile,
} from '../utils/serializer';
import { useAuth } from '../auth/AuthContext';
import { PaywallModal } from '../payments/PaywallModal';
import { addToast } from './Toast';
import { useSaveStatus } from '../store/saveStatusStore';
function SaveIndicator() {
const status = useSaveStatus((s) => s.status);
if (status === 'saved') {
return (
<span
title="All changes saved to browser"
className="h-2 w-2 rounded-full bg-emerald-500 transition-colors duration-300 shrink-0"
/>
);
}
if (status === 'saving') {
return (
<span
title="Saving changes..."
className="h-2 w-2 rounded-full bg-amber-500 animate-pulse shrink-0"
/>
);
}
return (
<span
title="Unsaved changes"
className="h-2 w-2 rounded-full bg-amber-500 shrink-0"
/>
);
}
/**
* Toolbar — project actions (new/save/load/export/import), undo/redo,
* tools (measure, snap), view presets, and the flow-animation toggle.
*/
export function Toolbar({
onOpenBom,
onCheckBuild,
}: {
onOpenBom: () => void;
onCheckBuild: () => void;
}) {
const { token, user, logout, refreshProfile } = useAuth();
const [showPaywall, setShowPaywall] = useState(false);
const [projectsList, setProjectsList] = useState<{ name: string; savedAt: string; parts: PlacedPart[] }[]>([]);
const [loadingProjects, setLoadingProjects] = useState(false);
const projectName = useBuilder((s) => s.projectName);
const setProjectName = useBuilder((s) => s.setProjectName);
const growthStage = useBuilder((s) => s.growthStage);
const setGrowthStage = useBuilder((s) => s.setGrowthStage);
const viewMode = useBuilder((s) => s.viewMode);
const setViewMode = useBuilder((s) => s.setViewMode);
const renderMode = useBuilder((s) => s.renderMode);
const setRenderMode = useBuilder((s) => s.setRenderMode);
const tool = useBuilder((s) => s.tool);
const setTool = useBuilder((s) => s.setTool);
const showFlow = useBuilder((s) => s.showFlow);
const toggleFlow = useBuilder((s) => s.toggleFlow);
const simRunning = useBuilder((s) => s.simRunning);
const toggleSimRunning = useBuilder((s) => s.toggleSimRunning);
const snapToGrid = useBuilder((s) => s.snapToGrid);
const toggleSnap = useBuilder((s) => s.toggleSnap);
const undo = useBuilder((s) => s.undo);
const redo = useBuilder((s) => s.redo);
const canUndo = useBuilder((s) => s.past.length > 0);
const canRedo = useBuilder((s) => s.future.length > 0);
const autoCapOpenEnds = useBuilder((s) => s.autoCapOpenEnds);
const rooms = useBuilder((s) => s.rooms);
const activeRoomId = useBuilder((s) => s.activeRoomId);
const addRoom = useBuilder((s) => s.addRoom);
const removeRoom = useBuilder((s) => s.removeRoom);
const setActiveRoomId = useBuilder((s) => s.setActiveRoomId);
const fileRef = useRef<HTMLInputElement>(null);
const [loadOpen, setLoadOpen] = useState(false);
const [showShortcuts, setShowShortcuts] = useState(false);
const fetchProjects = async () => {
if (!token) return;
setLoadingProjects(true);
try {
const response = await fetch('/api/projects', {
headers: { Authorization: `Bearer ${token}` }
});
if (response.ok) {
const data = await response.json();
setProjectsList(data);
}
} catch (err) {
console.error('Failed to fetch projects', err);
} finally {
setLoadingProjects(false);
}
};
useEffect(() => {
if (loadOpen) {
void fetchProjects();
}
}, [loadOpen]);
const handleSave = async () => {
if (!token) return;
const s = useBuilder.getState();
const partsArray = Object.values(s.parts);
try {
const response = await fetch('/api/projects', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({ name: s.projectName, parts: partsArray })
});
const data = await response.json();
if (!response.ok) {
if (response.status === 402) {
setShowPaywall(true);
} else {
addToast(data.error || 'Failed to save project', 'error');
}
return;
}
await refreshProfile();
addToast('Project saved successfully!', 'success');
} catch (err) {
addToast('Save failed: ' + err, 'error');
}
};
const handleLoad = (name: string) => {
const proj = projectsList.find((p) => p.name === name);
if (proj) {
useBuilder.getState().loadParts(proj.parts, proj.name);
}
setLoadOpen(false);
};
const handleDelete = async (e: React.MouseEvent, name: string) => {
e.stopPropagation();
if (!token || !confirm(`Delete project "${name}" permanently?`)) return;
try {
const response = await fetch(`/api/projects/${encodeURIComponent(name)}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` }
});
if (response.ok) {
await fetchProjects();
await refreshProfile();
addToast('Project deleted successfully', 'success');
} else {
const data = await response.json();
addToast(data.error || 'Failed to delete project', 'error');
}
} catch (err) {
addToast('Delete failed: ' + err, 'error');
}
};
const handleExport = () => {
const s = useBuilder.getState();
downloadProject(s.projectName, Object.values(s.parts));
};
const handleImport = async (file: File) => {
try {
const proj = await importProjectFile(file);
useBuilder.getState().loadParts(proj.parts, proj.name);
addToast('Project imported successfully', 'success');
} catch (err) {
addToast(err instanceof Error ? err.message : 'Import failed', 'error');
}
};
const handleNew = () => {
if (confirm('Start a new empty project? Unsaved changes are kept in undo history.')) {
useBuilder.getState().clearAll();
setProjectName('Untitled system');
}
};
const views: { id: ViewMode; label: string; title: string }[] = [
{ id: 'orbit', label: '3D', title: 'Free 3D orbit view' },
{ id: 'top', label: 'Top', title: 'Top-down plan view' },
{ id: 'front', label: 'Front', title: 'Front elevation view' },
{ id: 'side', label: 'Side', title: 'Side elevation view' },
];
const renderModes: { id: RenderMode; label: string; title: string }[] = [
{ id: 'builder', label: 'Builder', title: 'Standard building mode' },
{ id: 'xray', label: 'X-Ray', title: 'Transparent shell with internal water visibility' },
{ id: 'water', label: 'Water', title: 'Water-first view with solids faded back' },
];
return (
<>
<header className="flex h-12 shrink-0 items-center gap-2 border-b border-zinc-800 bg-zinc-950 px-3">
<div className="flex items-center gap-2 pr-2">
<span className="grid h-7 w-7 place-items-center rounded-lg bg-gradient-to-br from-sky-500 to-blue-700 text-sm font-black text-white">
H
</span>
<input
value={projectName}
onChange={(e) => setProjectName(e.target.value)}
className="w-44 rounded-md border border-transparent bg-transparent px-1.5 py-1 text-sm font-semibold text-zinc-100 outline-none hover:border-zinc-800 focus:border-sky-700 focus:bg-zinc-900"
/>
<SaveIndicator />
</div>
<Divider />
<TBtn onClick={handleNew} title="New empty project">New</TBtn>
<TBtn onClick={handleSave} title="Save to cloud storage">Save</TBtn>
<div className="relative">
<TBtn onClick={() => setLoadOpen((o) => !o)} title="Load a saved project">
Load
</TBtn>
{loadOpen && (
<div className="absolute top-9 left-0 z-50 max-h-72 w-64 overflow-y-auto rounded-lg border border-zinc-700 bg-zinc-900 p-1 shadow-2xl">
{loadingProjects && (
<div className="px-3 py-2 text-xs text-zinc-500">Loading...</div>
)}
{!loadingProjects && projectsList.length === 0 && (
<div className="px-3 py-2 text-xs text-zinc-500">No saved projects yet.</div>
)}
{!loadingProjects && projectsList.map((p) => (
<div
key={p.name}
className="flex items-center justify-between rounded-md px-3 py-1.5 hover:bg-zinc-800 group"
>
<button
onClick={() => handleLoad(p.name)}
className="flex-1 text-left text-xs text-zinc-200 outline-none"
>
{p.name}
<span className="block text-[10px] text-zinc-500">
{new Date(p.savedAt).toLocaleString()}
</span>
</button>
<button
onClick={(e) => handleDelete(e, p.name)}
className="hidden group-hover:block text-[10px] text-red-400 hover:text-red-300 px-2 py-0.5 rounded bg-zinc-950/40"
title="Delete project"
>
</button>
</div>
))}
</div>
)}
</div>
<TBtn onClick={handleExport} title="Download design as JSON">Export</TBtn>
<TBtn onClick={() => fileRef.current?.click()} title="Import a design JSON">Import</TBtn>
<TBtn onClick={() => setShowShortcuts(true)} title="Show Keyboard Shortcuts"> Shortcuts</TBtn>
<input
ref={fileRef}
type="file"
accept="application/json,.json"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) void handleImport(f);
e.target.value = '';
}}
/>
<Divider />
<TBtn onClick={onOpenBom} title="Open bill of materials">BOM</TBtn>
<TBtn onClick={onCheckBuild} title="Run design validation">Check Build</TBtn>
<TBtn onClick={() => autoCapOpenEnds()} title="Add caps to every loose plumbing end">
Auto Cap
</TBtn>
<Divider />
<TBtn onClick={undo} disabled={!canUndo} title="Undo (⌘Z)"></TBtn>
<TBtn onClick={redo} disabled={!canRedo} title="Redo (⇧⌘Z)"></TBtn>
<Divider />
<TBtn
active={tool === 'measure'}
onClick={() => setTool(tool === 'measure' ? 'select' : 'measure')}
title="Measure: click two points in the scene"
>
📏 Measure
</TBtn>
<TBtn
active={tool === 'pipeRun'}
onClick={() => setTool(tool === 'pipeRun' ? 'select' : 'pipeRun')}
title="Pipe Run: click two ground points"
>
Pipe Run
</TBtn>
<TBtn active={snapToGrid} onClick={toggleSnap} title="Snap to grid (0.25 ft)">
Snap
</TBtn>
<div className="ml-auto flex items-center gap-2">
{/* Master play / pause for the water simulation */}
<button
onClick={toggleSimRunning}
title={simRunning ? 'Pause water simulation (Space)' : 'Run water simulation (Space)'}
className={`grid h-8 w-8 shrink-0 place-items-center rounded-full text-white shadow-lg transition-all hover:scale-110 active:scale-95 ${
simRunning
? 'bg-emerald-500 shadow-emerald-500/40 hover:bg-emerald-400'
: 'bg-sky-600 shadow-sky-600/40 hover:bg-sky-500'
}`}
>
{simRunning ? (
<svg viewBox="0 0 16 16" className="h-3.5 w-3.5" fill="currentColor">
<rect x="3" y="2.5" width="3.4" height="11" rx="1" />
<rect x="9.6" y="2.5" width="3.4" height="11" rx="1" />
</svg>
) : (
<svg viewBox="0 0 16 16" className="ml-0.5 h-3.5 w-3.5" fill="currentColor">
<path d="M4 2.8a1 1 0 0 1 1.52-.86l8.2 5.2a1 1 0 0 1 0 1.7l-8.2 5.2A1 1 0 0 1 4 13.2V2.8Z" />
</svg>
)}
</button>
<div className="flex items-center gap-1.5 bg-zinc-900 border border-zinc-800 rounded-lg px-2.5 py-1">
<span className="text-[10px] uppercase font-bold text-zinc-400">Stage</span>
<select
value={growthStage}
onChange={(e) => setGrowthStage(e.target.value as any)}
className="bg-transparent text-xs text-zinc-200 outline-none border-none cursor-pointer pr-1"
>
<option value="seedling" className="bg-zinc-900 text-zinc-200">Seedling</option>
<option value="vegetative" className="bg-zinc-900 text-zinc-200">Vegetative</option>
<option value="flowering" className="bg-zinc-900 text-zinc-200">Flowering</option>
</select>
</div>
<TBtn active={showFlow} onClick={toggleFlow} title="Toggle water flow animation">
💧 Flow
</TBtn>
<div className="flex overflow-hidden rounded-lg border border-zinc-800">
{renderModes.map((mode) => (
<button
key={mode.id}
onClick={() => setRenderMode(mode.id)}
title={mode.title}
className={`px-3 py-1.5 text-xs font-medium transition ${
renderMode === mode.id
? 'bg-cyan-600 text-white'
: 'bg-zinc-900 text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200'
}`}
>
{mode.label}
</button>
))}
</div>
<div className="flex overflow-hidden rounded-lg border border-zinc-800">
{views.map((v) => (
<button
key={v.id}
onClick={() => setViewMode(v.id)}
title={v.title}
className={`px-3 py-1.5 text-xs font-medium transition ${
viewMode === v.id
? 'bg-sky-600 text-white'
: 'bg-zinc-900 text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200'
}`}
>
{v.label}
</button>
))}
</div>
{user && (
<>
<Divider />
<div className="flex items-center gap-2 px-1">
<div className="flex flex-col items-end">
<span className="text-[10px] font-semibold text-zinc-300 max-w-24 truncate">
{user.email}
</span>
<span className={`text-[9px] font-bold uppercase ${user.total_slots === 0 ? 'text-amber-400' : user.project_count >= user.total_slots ? 'text-amber-400' : 'text-emerald-400'}`}>
{user.project_count} / {user.total_slots} Slots
</span>
</div>
{user.total_slots === 0 ? (
<button
onClick={() => setShowPaywall(true)}
className="rounded bg-gradient-to-r from-sky-500/20 to-blue-500/20 hover:from-sky-500/30 hover:to-blue-500/30 text-sky-300 border border-sky-700 px-2 py-1 text-[10px] font-bold transition cursor-pointer flex items-center gap-1 shadow-lg"
>
💎 Upgrade
</button>
) : (
user.project_count >= user.total_slots && (
<button
onClick={() => setShowPaywall(true)}
className="rounded bg-amber-500/20 hover:bg-amber-500/30 text-amber-300 border border-amber-600/30 px-2 py-1 text-[10px] font-semibold transition cursor-pointer"
>
+ Add Slot
</button>
)
)}
<button
onClick={logout}
className="rounded border border-zinc-800 bg-zinc-900 px-2 py-1 text-[10px] font-medium text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200 transition cursor-pointer"
>
Logout
</button>
</div>
</>
)}
</div>
{showPaywall && <PaywallModal onClose={() => setShowPaywall(false)} />}
{showShortcuts && (
<div className="fixed inset-0 z-[120] flex items-center justify-center bg-zinc-950/80 backdrop-blur-sm">
<div className="w-full max-w-sm rounded-xl border border-zinc-800 bg-zinc-900 p-6 shadow-2xl text-left">
<h3 className="text-sm font-bold text-zinc-100 mb-4 flex items-center gap-1.5 border-b border-zinc-800 pb-2">
Keyboard Shortcuts
</h3>
<div className="space-y-2.5 text-xs text-zinc-400">
<div className="flex justify-between border-b border-zinc-800/40 pb-1.5">
<span>Undo / Redo</span>
<span className="font-mono bg-zinc-950 px-1.5 py-0.5 rounded text-zinc-300">Z / Y</span>
</div>
<div className="flex justify-between border-b border-zinc-800/40 pb-1.5">
<span>Rotate Selected</span>
<span className="font-mono bg-zinc-950 px-1.5 py-0.5 rounded text-zinc-300">R</span>
</div>
<div className="flex justify-between border-b border-zinc-800/40 pb-1.5">
<span>Duplicate Selected</span>
<span className="font-mono bg-zinc-950 px-1.5 py-0.5 rounded text-zinc-300">D</span>
</div>
<div className="flex justify-between border-b border-zinc-800/40 pb-1.5">
<span>Delete Selected</span>
<span className="font-mono bg-zinc-950 px-1.5 py-0.5 rounded text-zinc-300"> / Del</span>
</div>
<div className="flex justify-between border-b border-zinc-800/40 pb-1.5">
<span>Run / Pause Sim</span>
<span className="font-mono bg-zinc-950 px-1.5 py-0.5 rounded text-zinc-300">Spacebar</span>
</div>
<div className="flex justify-between border-b border-zinc-800/40 pb-1.5">
<span>Pan Camera View</span>
<span className="font-mono bg-zinc-950 px-1.5 py-0.5 rounded text-zinc-300">Arrow Keys</span>
</div>
<div className="flex justify-between border-b border-zinc-800/40 pb-1.5">
<span>Clear Selection & Tool</span>
<span className="font-mono bg-zinc-950 px-1.5 py-0.5 rounded text-zinc-300">Esc</span>
</div>
</div>
<button
onClick={() => setShowShortcuts(false)}
className="mt-6 w-full rounded bg-zinc-800 py-2 text-xs font-semibold text-zinc-200 hover:bg-zinc-700 transition cursor-pointer"
>
Close
</button>
</div>
</div>
)}
</header>
{/* Tabbed room bar */}
<div className="flex h-10 shrink-0 items-center justify-between border-b border-zinc-850 bg-zinc-900/60 px-3">
<div className="flex items-center gap-1.5 overflow-x-auto">
{rooms.map((room) => {
const isActive = room.id === activeRoomId;
return (
<div
key={room.id}
className={`group flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-semibold transition cursor-pointer select-none ${
isActive
? 'bg-sky-500/15 text-sky-400 border border-sky-500/30'
: 'text-zinc-400 hover:text-zinc-200 border border-transparent'
}`}
onClick={() => setActiveRoomId(room.id)}
>
<span>{room.name}</span>
{rooms.length > 1 && (
<button
onClick={(e) => {
e.stopPropagation();
if (confirm(`Delete room "${room.name}" and all its parts permanently?`)) {
removeRoom(room.id);
}
}}
className="opacity-0 group-hover:opacity-100 hover:text-red-400 text-[10px] ml-1 transition"
>
</button>
)}
</div>
);
})}
</div>
<button
onClick={() => {
const name = prompt('Enter room name:', `Room ${rooms.length + 1}`);
if (name) {
addRoom(name.trim());
}
}}
className="rounded border border-zinc-700 bg-zinc-850 hover:bg-zinc-800 text-zinc-300 px-2.5 py-1 text-[11px] font-semibold transition flex items-center gap-1 cursor-pointer"
>
+ Add Room
</button>
</div>
</>
);
}
function Divider() {
return <div className="mx-1 h-6 w-px bg-zinc-800" />;
}
function TBtn({
children,
onClick,
title,
active,
disabled,
}: {
children: React.ReactNode;
onClick: () => void;
title?: string;
active?: boolean;
disabled?: boolean;
}) {
return (
<button
onClick={onClick}
title={title}
disabled={disabled}
className={`rounded-md px-2.5 py-1.5 text-xs font-medium transition disabled:cursor-default disabled:opacity-30
${
active
? 'bg-sky-500/15 text-sky-300 ring-1 ring-sky-700'
: 'text-zinc-300 hover:bg-zinc-800'
}`}
>
{children}
</button>
);
}

View File

@@ -0,0 +1,428 @@
import type { ComponentType, ReactNode } from 'react';
import { PART_DEFS } from '../../parts/catalog';
/**
* Miniature pictographic icons for the parts library.
*
* Visual language: 40x40 viewBox, ~2px rounded light strokes (#cbd5e1),
* with each part's catalog accent color used as fill/highlight.
* Unknown part types fall back to a generic colored tile.
*/
export interface PartIconProps {
/** Accent color from the part catalog (`def.color`). */
color: string;
className?: string;
}
const STROKE = '#cbd5e1';
const LEAF = '#4ade80';
function Svg({ children, className }: { children: ReactNode; className?: string }) {
return (
<svg
viewBox="0 0 40 40"
fill="none"
stroke={STROKE}
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className={className}
aria-hidden="true"
>
{children}
</svg>
);
}
/** Straight pipe segment with couplings at both ends. */
function PipeIcon({ color, className }: PartIconProps) {
return (
<Svg className={className}>
<rect x="8" y="15" width="24" height="10" rx="2" fill={color} fillOpacity={0.35} />
<rect x="4" y="12.5" width="5" height="15" rx="1.5" fill={color} fillOpacity={0.55} />
<rect x="31" y="12.5" width="5" height="15" rx="1.5" fill={color} fillOpacity={0.55} />
<path d="M12 18.5 H28" strokeOpacity={0.45} strokeWidth={1.5} />
</Svg>
);
}
/** 90-degree elbow bend with flanged ends. */
function ElbowIcon({ color, className }: PartIconProps) {
return (
<Svg className={className}>
<path
d="M5 10 H22 A8 8 0 0 1 30 18 V35 H20 V20 H5 Z"
fill={color}
fillOpacity={0.35}
/>
<rect x="3" y="8" width="4" height="14" rx="1.5" fill={color} fillOpacity={0.55} />
<rect x="18" y="33" width="14" height="4" rx="1.5" fill={color} fillOpacity={0.55} />
</Svg>
);
}
/** T-joint: horizontal run with a center branch. */
function TeeIcon({ color, className }: PartIconProps) {
return (
<Svg className={className}>
<path d="M5 13 H35 V23 H25 V35 H15 V23 H5 Z" fill={color} fillOpacity={0.35} />
<rect x="3" y="11" width="4" height="14" rx="1.5" fill={color} fillOpacity={0.55} />
<rect x="33" y="11" width="4" height="14" rx="1.5" fill={color} fillOpacity={0.55} />
<rect x="13" y="33" width="14" height="4" rx="1.5" fill={color} fillOpacity={0.55} />
</Svg>
);
}
/** Inline valve: bowtie body, stem, and handle wheel. */
function ValveIcon({ color, className }: PartIconProps) {
return (
<Svg className={className}>
<path d="M6 15 L20 21.5 L6 28 Z" fill={color} fillOpacity={0.35} />
<path d="M34 15 L20 21.5 L34 28 Z" fill={color} fillOpacity={0.35} />
<path d="M20 21.5 V11" />
<ellipse cx="20" cy="9" rx="8" ry="3" stroke={color} fill={color} fillOpacity={0.3} />
</Svg>
);
}
/** Centrifugal pump: round volute, impeller spokes, outlet duct, feet. */
function PumpIcon({ color, className }: PartIconProps) {
return (
<Svg className={className}>
<rect x="23" y="11" width="13" height="7" rx="1.5" fill={color} fillOpacity={0.45} />
<circle cx="18" cy="23" r="10" fill={color} fillOpacity={0.25} />
<path d="M18 23 L18 16" strokeWidth={1.8} />
<path d="M18 23 L12 26.5" strokeWidth={1.8} />
<path d="M18 23 L24 26.5" strokeWidth={1.8} />
<circle cx="18" cy="23" r="1.8" fill={STROKE} stroke="none" />
<path d="M10 36 H26" />
</Svg>
);
}
/** Reservoir tank with a wavy water line. */
function ReservoirIcon({ color, className }: PartIconProps) {
return (
<Svg className={className}>
<rect x="7" y="9" width="26" height="25" rx="3" />
<path
d="M8.5 21 Q12 18.5 15.5 21 T22.5 21 T29.5 21 L31.5 21 V29.5 Q31.5 32.5 28.5 32.5 H11.5 Q8.5 32.5 8.5 29.5 Z"
fill={color}
fillOpacity={0.6}
stroke="none"
/>
<path d="M8.5 21 Q12 18.5 15.5 21 T22.5 21 T29.5 21 L31.5 21" stroke={color} strokeWidth={1.8} />
</Svg>
);
}
/** Vertical grow tower with angled plant cups and foliage. */
function TowerIcon({ color, className }: PartIconProps) {
return (
<Svg className={className}>
<rect x="16" y="5" width="8" height="28" rx="2" fill={color} fillOpacity={0.25} />
<path d="M16 12 L10 9.5 V15 L16 16 Z" fill={color} fillOpacity={0.4} />
<path d="M24 19 L30 16.5 V22 L24 23 Z" fill={color} fillOpacity={0.4} />
<path d="M16 26 L10 23.5 V29 L16 30 Z" fill={color} fillOpacity={0.4} />
<path d="M10 9.5 Q7 7 7.5 4.5 Q11 5 11.5 8.5" fill={LEAF} stroke={LEAF} strokeWidth={1.2} />
<path d="M30 16.5 Q33 14 32.5 11.5 Q29 12 28.5 15.5" fill={LEAF} stroke={LEAF} strokeWidth={1.2} />
<path d="M10 23.5 Q7 21 7.5 18.5 Q11 19 11.5 22.5" fill={LEAF} stroke={LEAF} strokeWidth={1.2} />
<rect x="12" y="33" width="16" height="3.5" rx="1.5" fill={color} fillOpacity={0.4} />
</Svg>
);
}
/** Shallow flood tray in light perspective with water ripples. */
function TrayIcon({ color, className }: PartIconProps) {
return (
<Svg className={className}>
<path d="M6 21 L14 14 H34 L26 21 Z" fill={color} fillOpacity={0.25} />
<path d="M6 21 H26 V28 H6 Z" fill={color} fillOpacity={0.45} />
<path d="M26 21 L34 14 V21 L26 28 Z" fill={color} fillOpacity={0.35} />
<path d="M12 18.5 Q14 17 16 18.5" strokeWidth={1.4} strokeOpacity={0.8} />
<path d="M20 17.5 Q22 16 24 17.5" strokeWidth={1.4} strokeOpacity={0.8} />
</Svg>
);
}
/** Vertical grow wall: panel with a grid of planting pockets. */
function WallPanelIcon({ color, className }: PartIconProps) {
return (
<Svg className={className}>
<rect x="8" y="6" width="24" height="28" rx="2" fill={color} fillOpacity={0.2} />
{[12.5, 20.5, 28.5].map((y) =>
[13, 23].map((x) => (
<path
key={`${x}-${y}`}
d={`M${x} ${y} q2 2.5 4 0`}
stroke={color}
strokeWidth={1.8}
fill="none"
/>
)),
)}
<path d="M14 11 q1 -2.5 3 -3" stroke={LEAF} strokeWidth={1.4} />
<path d="M24 19 q1 -2.5 3 -3" stroke={LEAF} strokeWidth={1.4} />
<path d="M14 27 q1 -2.5 3 -3" stroke={LEAF} strokeWidth={1.4} />
</Svg>
);
}
/** Structural lattice: frame with criss-cross diagonals. */
function LatticeIcon({ color, className }: PartIconProps) {
return (
<Svg className={className}>
<rect x="7" y="7" width="26" height="26" rx="2" />
<g stroke={color} strokeWidth={1.6}>
<path d="M7 21 L21 7" />
<path d="M7 33 L33 7" />
<path d="M19 33 L33 19" />
<path d="M19 7 L33 21" />
<path d="M7 7 L33 33" />
<path d="M7 19 L21 33" />
</g>
</Svg>
);
}
/** Net pot: meshed cup with a seedling sprout. */
function NetPotIcon({ color, className }: PartIconProps) {
return (
<Svg className={className}>
<path d="M11 19 H29 L25.5 33 H14.5 Z" fill={color} fillOpacity={0.35} />
<path d="M14 22 L17 30 M19 22 L20 30 M24 22 L22 30" strokeWidth={1.3} strokeOpacity={0.7} />
<rect x="8.5" y="15.5" width="23" height="4" rx="1.5" fill={color} fillOpacity={0.55} />
<path d="M20 15 V8" stroke={LEAF} />
<path d="M20 10 C16.5 10 14.5 7.5 14.5 4.5 C18 4.5 20 7 20 10 Z" fill={LEAF} stroke={LEAF} strokeWidth={1} />
<path d="M20 10 C23.5 10 25.5 7.5 25.5 4.5 C22 4.5 20 7 20 10 Z" fill={LEAF} stroke={LEAF} strokeWidth={1} />
</Svg>
);
}
/** Spray emitter: nozzle with fanning spray and droplets. */
function EmitterIcon({ color, className }: PartIconProps) {
return (
<Svg className={className}>
<path d="M20 17 V10" stroke={color} strokeWidth={1.8} />
<path d="M15.5 18.5 L10.5 12.5" stroke={color} strokeWidth={1.8} />
<path d="M24.5 18.5 L29.5 12.5" stroke={color} strokeWidth={1.8} />
<circle cx="20" cy="7.5" r="1.6" fill={color} stroke="none" />
<circle cx="9" cy="10.5" r="1.6" fill={color} stroke="none" />
<circle cx="31" cy="10.5" r="1.6" fill={color} stroke="none" />
<rect x="17.5" y="20" width="5" height="5" rx="1" fill={color} fillOpacity={0.5} />
<path d="M14 25 H26 L24 32 H16 Z" fill={color} fillOpacity={0.3} />
<path d="M12 35 H28" />
</Svg>
);
}
/** Drain: funnel with grate slats and a falling droplet. */
function DrainIcon({ color, className }: PartIconProps) {
return (
<Svg className={className}>
<path d="M7 9 H33 L24 21 V29 L16 26 V21 Z" fill={color} fillOpacity={0.3} />
<path d="M14 12.5 V16" strokeWidth={1.5} strokeOpacity={0.8} />
<path d="M20 12.5 V19.5" strokeWidth={1.5} strokeOpacity={0.8} />
<path d="M26 12.5 V16" strokeWidth={1.5} strokeOpacity={0.8} />
<path
d="M20 31.5 C18 34.5 18.4 36.8 20 36.8 C21.6 36.8 22 34.5 20 31.5 Z"
fill={color}
stroke="none"
/>
</Svg>
);
}
function GrowTentIcon({ color, className }: PartIconProps) {
return (
<Svg className={className}>
<path d="M7 33 V10 L12 6 H28 L33 10 V33" fill={color} fillOpacity={0.18} />
<path d="M12 6 V33 M28 6 V33 M7 15 H33" stroke={color} strokeWidth={1.6} />
<path d="M20 15 V33" strokeWidth={1.4} strokeOpacity={0.7} />
</Svg>
);
}
function InlineFanIcon({ color, className }: PartIconProps) {
return (
<Svg className={className}>
<circle cx="20" cy="20" r="11" fill={color} fillOpacity={0.25} />
<circle cx="20" cy="20" r="4" fill={color} fillOpacity={0.6} />
<path d="M20 9 C25 11 27 15 25 20" stroke={color} />
<path d="M31 20 C29 25 25 27 20 25" stroke={color} />
<path d="M20 31 C15 29 13 25 15 20" stroke={color} />
<path d="M9 20 C11 15 15 13 20 15" stroke={color} />
</Svg>
);
}
function CirculationFanIcon({ color, className }: PartIconProps) {
return (
<Svg className={className}>
<circle cx="20" cy="15" r="8" fill={color} fillOpacity={0.18} />
<path d="M20 10 C23 11 24.5 13 23.5 16" stroke={color} />
<path d="M25 15 C24 18 22 19.5 19 18.5" stroke={color} />
<path d="M20 20 C17 19 15.5 17 16.5 14" stroke={color} />
<path d="M20 23 V34" stroke={color} />
<path d="M14 34 H26" stroke={color} />
</Svg>
);
}
function GrowLightIcon({ color, className }: PartIconProps) {
return (
<Svg className={className}>
<rect x="8" y="9" width="24" height="8" rx="2" fill={color} fillOpacity={0.4} />
<path d="M12 9 L15 5 M28 9 L25 5" />
<path d="M12 21 L10 27 M18 21 L18 29 M24 21 L26 27 M30 21 L32 27" stroke={color} />
</Svg>
);
}
function Co2TankIcon({ color, className }: PartIconProps) {
return (
<Svg className={className}>
<rect x="14" y="8" width="12" height="24" rx="4" fill={color} fillOpacity={0.25} />
<rect x="17" y="5" width="6" height="4" rx="1.5" fill={color} fillOpacity={0.5} />
<path d="M23 7 H28" />
<text x="20" y="23" textAnchor="middle" fontSize="7" fill={STROKE} stroke="none">
CO2
</text>
</Svg>
);
}
/** Fallback tile for unknown/new part types. */
function GenericPartIcon({ color, className }: PartIconProps) {
return (
<Svg className={className}>
<rect x="9" y="9" width="22" height="22" rx="5" fill={color} fillOpacity={0.4} />
<circle cx="20" cy="20" r="3" fill={color} stroke="none" />
</Svg>
);
}
/** Air Compressor: rectangular box with nozzle and dial. */
function AirCompressorIcon({ color, className }: PartIconProps) {
return (
<Svg className={className}>
<rect x="8" y="14" width="24" height="16" rx="3" fill={color} fillOpacity={0.25} />
<circle cx="12" cy="32" r="2" fill={color} fillOpacity={0.6} />
<circle cx="28" cy="32" r="2" fill={color} fillOpacity={0.6} />
<rect x="30" y="19" width="4" height="4" fill={STROKE} />
<circle cx="16" cy="22" r="3" fill={color} fillOpacity={0.4} />
<line x1="16" y1="22" x2="18" y2="20" stroke={STROKE} strokeWidth={1.5} />
</Svg>
);
}
/**
* Keyed by part type string (not the PartType union) so new catalog
* entries degrade gracefully to the generic tile until an icon exists.
*/
const PART_ICONS: Record<string, ComponentType<PartIconProps>> = {
pipe: PipeIcon,
elbow: ElbowIcon,
tee: TeeIcon,
valve: ValveIcon,
pump: PumpIcon,
reservoir: ReservoirIcon,
tower: TowerIcon,
tray: TrayIcon,
wallPanel: WallPanelIcon,
lattice: LatticeIcon,
netPot: NetPotIcon,
emitter: EmitterIcon,
drain: DrainIcon,
growTent: GrowTentIcon,
inlineFan: InlineFanIcon,
circulationFan: CirculationFanIcon,
growLight: GrowLightIcon,
co2Tank: Co2TankIcon,
airCompressor: AirCompressorIcon,
reducingTee: ReducingTeeIcon,
reducingElbow: ReducingElbowIcon,
bulkhead: BulkheadIcon,
waterChiller: WaterChillerIcon,
reverseOsmosis: ReverseOsmosisIcon,
};
/** Reducing Tee: T-fitting with a thinner branch run. */
function ReducingTeeIcon({ color, className }: PartIconProps) {
return (
<Svg className={className}>
<path d="M 6,18 L 34,18" stroke={color} strokeWidth={8} strokeLinecap="round" />
<path d="M 6,18 L 34,18" stroke={STROKE} strokeWidth={1.5} />
<path d="M 20,18 L 20,30" stroke={color} strokeWidth={4.5} strokeLinecap="round" />
<path d="M 20,18 L 20,30" stroke={STROKE} strokeWidth={1.2} />
</Svg>
);
}
/** Reducing Elbow: 90-degree bend with a tapered arc. */
function ReducingElbowIcon({ color, className }: PartIconProps) {
return (
<Svg className={className}>
<path d="M 8,28 A 12,12 0 0,1 28,8" fill="none" stroke={color} strokeWidth={7} strokeLinecap="round" />
<path d="M 8,28 A 12,12 0 0,1 28,8" fill="none" stroke={STROKE} strokeWidth={1.5} />
<line x1="8" y1="28" x2="12" y2="28" stroke={STROKE} strokeWidth={1.5} />
<line x1="28" y1="8" x2="28" y2="12" stroke={STROKE} strokeWidth={1.5} />
</Svg>
);
}
/** Bulkhead: flange fitting representing tank connection. */
function BulkheadIcon({ color, className }: PartIconProps) {
return (
<Svg className={className}>
<rect x="18" y="6" width="4" height="28" fill={color} fillOpacity={0.4} stroke={color} strokeWidth={1.2} />
<rect x="10" y="14" width="20" height="12" fill={color} fillOpacity={0.25} rx="1" />
<rect x="12" y="16" width="16" height="8" fill="none" stroke={STROKE} strokeWidth={1.5} />
</Svg>
);
}
/** Water Chiller: cool metal cabinet with ports and digital display. */
function WaterChillerIcon({ color, className }: PartIconProps) {
return (
<Svg className={className}>
<rect x="8" y="12" width="24" height="22" rx="2" fill={color} fillOpacity={0.25} />
<rect x="12" y="7" width="3" height="5" fill={color} />
<rect x="25" y="7" width="3" height="5" fill={color} />
<rect x="14" y="17" width="12" height="6" fill="#09090b" rx="1" />
<circle cx="20" cy="20" r="1.5" fill="#22c55e" />
<line x1="12" y1="27" x2="28" y2="27" stroke={STROKE} strokeWidth={1.2} />
<line x1="12" y1="30" x2="28" y2="30" stroke={STROKE} strokeWidth={1.2} />
</Svg>
);
}
/** Reverse Osmosis Filter: inline blue canister filter with inlet/outlet. */
function ReverseOsmosisIcon({ color, className }: PartIconProps) {
return (
<Svg className={className}>
<rect x="4" y="18" width="32" height="4" rx="1" fill={color} fillOpacity={0.4} />
<rect x="11" y="10" width="18" height="20" rx="3" fill={color} fillOpacity={0.25} />
<line x1="11" y1="15" x2="29" y2="15" stroke={STROKE} strokeWidth={1.5} />
<line x1="11" y1="20" x2="29" y2="20" stroke={STROKE} strokeWidth={1.5} />
<line x1="11" y1="25" x2="29" y2="25" stroke={STROKE} strokeWidth={1.5} />
</Svg>
);
}
export function PartIcon({
type,
color,
className,
}: {
type: string;
/** Accent color override; defaults to the catalog color for the type. */
color?: string;
className?: string;
}) {
const Icon = PART_ICONS[type] ?? GenericPartIcon;
const accent =
color ?? (PART_DEFS as Record<string, { color: string } | undefined>)[type]?.color ?? '#71717a';
return <Icon color={accent} className={className} />;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,162 @@
import type { PlacedPart } from '../types';
export interface RoomMetrics {
roomId: string;
label: string;
sealed: boolean;
volumeFt3: number;
exhaustCfm: number;
circulationCfm: number;
lightWatts: number;
co2TankCount: number;
co2Cfh: number;
airChangesPerMinute: number;
airChangesPerHour: number;
roomPPFD: number;
roomDLI: number;
roomTempF: number;
roomRH: number;
roomVPD: number;
roomCo2: number;
sensibleHeatGainsBtuHr: number;
latentCoolingBtuHr: number;
acSizingBtuHr: number;
dehumidifierPintsDay: number;
}
function contains(room: PlacedPart, part: PlacedPart): boolean {
const width = room.params.width ?? 0;
const depth = room.params.depth ?? 0;
const height = room.params.height ?? 0;
const [rx, ry, rz] = room.position;
const [px, py, pz] = part.position;
return (
px >= rx - width / 2 &&
px <= rx + width / 2 &&
pz >= rz - depth / 2 &&
pz <= rz + depth / 2 &&
py >= ry &&
py <= ry + height
);
}
export function computeRoomMetrics(partsMap: Record<string, PlacedPart>): RoomMetrics[] {
const parts = Object.values(partsMap);
const rooms = parts.filter((part) => part.type === 'growTent');
return rooms.map((room) => {
const roomParts = parts.filter((part) => part.id !== room.id && contains(room, part));
const exhaustCfm = 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);
const lightWatts = roomParts
.filter((part) => part.type === 'growLight')
.reduce((sum, part) => sum + (part.params.watts ?? 0), 0);
const co2 = roomParts.filter((part) => part.type === 'co2Tank');
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 canopyArea = (room.params.width ?? 0) * (room.params.depth ?? 0);
const roomPPFD = canopyArea > 0 ? (lightWatts * 2.0) / canopyArea : 0;
const lights = roomParts.filter((part) => part.type === 'growLight');
const roomDLI = canopyArea > 0 && lights.length > 0
? lights.reduce((sum, light) => {
const w = light.params.watts ?? 0;
const p = light.params.photoperiod ?? 18;
const lightPPFD = (w * 2.0) / canopyArea;
return sum + lightPPFD * p * 0.0036;
}, 0)
: 0;
const activeLightWatts = lights.reduce((sum, light) => {
const p = light.params.photoperiod ?? 18;
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 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;
if (isSealed && hasLightsOn) {
if (co2.length === 0) {
roomCo2 = 150;
} else {
roomCo2 = 1200;
}
} else if (co2.length > 0) {
roomCo2 = 1200;
}
// 1. Sensible Heat Gains (BTU/hr)
const sensibleHeatGainsBtuHr = activeLightWatts * 3.412;
// 2. Canopy Transpiration Latent Cooling (BTU/hr)
const f_VPD = Math.max(0.1, Math.min(1.0, roomVPD / 1.2));
const transpirationPintsDay = activeLightWatts * 0.032 * f_VPD;
const latentCoolingBtuHr = (transpirationPintsDay / 24) * 1060;
// 3. Humidity Balance
const e_room = SVP * (roomRH / 100);
const rho_w_room = (0.01352 * e_room) / (T_c + 273.15);
// Outside reference conditions at 75°F and 50% RH
const T_c_amb = (75 - 32) * 5 / 9;
const SVP_amb = 0.61078 * Math.exp((17.27 * T_c_amb) / (T_c_amb + 237.3));
const e_amb = SVP_amb * 0.50;
const rho_w_amb = (0.01352 * e_amb) / (T_c_amb + 273.15);
// Moisture removal via exhaust ventilation
let moistureVentPintsDay = 0;
if (!isSealed && exhaustCfm > 0) {
moistureVentPintsDay = Math.max(0, exhaustCfm * 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);
}
const dehumHeatBtuHr = (dehumidifierPintsDay / 24) * 1200;
const netSensibleBtuHr = sensibleHeatGainsBtuHr - latentCoolingBtuHr - ventSensibleBtuHr + dehumHeatBtuHr;
const acSizingBtuHr = Math.max(0, netSensibleBtuHr * 1.15);
return {
roomId: room.id,
label: room.label ?? 'Grow room',
sealed: isSealed,
volumeFt3,
exhaustCfm,
circulationCfm,
lightWatts,
co2TankCount: co2.length,
co2Cfh,
airChangesPerMinute,
airChangesPerHour: airChangesPerMinute * 60,
roomPPFD,
roomDLI,
roomTempF,
roomRH,
roomVPD,
roomCo2,
sensibleHeatGainsBtuHr,
latentCoolingBtuHr,
acSizingBtuHr,
dehumidifierPintsDay,
};
});
}

159
src/hooks/useCollab.ts Normal file
View File

@@ -0,0 +1,159 @@
import { useEffect, useRef, useState } from 'react';
import { useBuilder } from '../store/builderStore';
import { useAuth } from '../auth/AuthContext';
const colors = [
'#38bdf8',
'#3d7bfa',
'#34d399',
'#3b7a57',
'#fbbf24',
'#f97316',
'#e0564f',
'#a78bfa',
];
const stringToColor = (str: string) => {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
const index = Math.abs(hash) % colors.length;
return colors[index];
};
export interface RemoteCursor {
position: [number, number, number];
userName: string;
color: string;
activeRoomId: string;
}
export function useCollab() {
const { user } = useAuth();
const parts = useBuilder((s) => s.parts);
const projectName = useBuilder((s) => s.projectName);
const [remoteCursors, setRemoteCursors] = useState<Record<string, RemoteCursor>>({});
const wsRef = useRef<WebSocket | null>(null);
const localPartsRef = useRef(parts);
const isIncomingUpdateRef = useRef(false);
useEffect(() => {
if (!user) return;
const wsProto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsPort = window.location.port === '5173' ? '3000' : window.location.port;
const wsUrl = `${wsProto}//${window.location.hostname}${wsPort ? `:${wsPort}` : ''}`;
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
ws.onopen = () => {
const color = stringToColor(user.email);
ws.send(
JSON.stringify({
type: 'join',
projectRoom: projectName,
userId: user.id,
userName: user.email,
color: color,
activeRoomId: useBuilder.getState().activeRoomId,
})
);
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'mutation') {
isIncomingUpdateRef.current = true;
localPartsRef.current = data.parts;
useBuilder.getState().syncParts(data.parts);
} else if (data.type === 'cursor') {
setRemoteCursors((prev) => ({
...prev,
[data.userId]: {
position: data.position,
userName: data.userName,
color: data.color,
activeRoomId: data.activeRoomId,
},
}));
} else if (data.type === 'leave') {
setRemoteCursors((prev) => {
const next = { ...prev };
delete next[data.userId];
return next;
});
}
} catch (err) {
console.error('Error handling WebSocket message:', err);
}
};
ws.onclose = () => {
console.log('Collaboration WebSocket disconnected');
};
ws.onerror = (err) => {
console.error('Collaboration WebSocket error:', err);
};
return () => {
ws.close();
wsRef.current = null;
};
}, [user, projectName]);
// Sync local changes to remote
useEffect(() => {
if (isIncomingUpdateRef.current) {
isIncomingUpdateRef.current = false;
localPartsRef.current = parts;
return;
}
if (parts !== localPartsRef.current) {
localPartsRef.current = parts;
const ws = wsRef.current;
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(
JSON.stringify({
type: 'mutation',
parts,
})
);
}
}
}, [parts]);
// Sync cursor movement
useEffect(() => {
let lastSent = 0;
const handleCursor = (e: Event) => {
const now = Date.now();
if (now - lastSent < 50) return; // rate limit to 20fps
lastSent = now;
const pos = (e as CustomEvent).detail;
const ws = wsRef.current;
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(
JSON.stringify({
type: 'cursor',
position: [pos.x, pos.y, pos.z],
activeRoomId: useBuilder.getState().activeRoomId,
})
);
}
};
window.addEventListener('collab:cursor', handleCursor);
return () => {
window.removeEventListener('collab:cursor', handleCursor);
};
}, []);
return { remoteCursors };
}

59
src/index.css Normal file
View File

@@ -0,0 +1,59 @@
@import "tailwindcss";
html,
body,
#root {
height: 100%;
margin: 0;
overflow: hidden;
background: #0a0e13;
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
/* Slim dark scrollbars */
*::-webkit-scrollbar {
width: 8px;
height: 8px;
}
*::-webkit-scrollbar-thumb {
background: #27272a;
border-radius: 4px;
}
*::-webkit-scrollbar-thumb:hover {
background: #3f3f46;
}
*::-webkit-scrollbar-track {
background: transparent;
}
/* Toast animations */
@utility animate-toast-in {
animation: toast-in 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
@utility animate-toast-out {
animation: toast-out 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
@keyframes toast-in {
from {
opacity: 0;
transform: translateX(100%);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes toast-out {
from {
opacity: 1;
transform: translateX(0);
}
to {
opacity: 0;
transform: translateX(100%);
}
}

10
src/main.tsx Normal file
View File

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

504
src/parts/catalog.ts Normal file
View File

@@ -0,0 +1,504 @@
import type { ConnectorDef, PartDefinition, PartType } from '../types';
/**
* Part catalog — the single source of truth for every placeable part:
* default parameters, inspector metadata, and connector layouts.
*
* To add a new part type:
* 1. Add its name to `PartType` in types.ts.
* 2. Add an entry here (defaults, params, connectors).
* 3. Add a mesh case in components/PartMesh.tsx.
* 4. (Optional) tune its resistance in simulation/flowSimulator.ts.
*/
/** Bend radius used by elbows (ft). */
export const ELBOW_BEND_RADIUS = 0.5;
/** Supported nominal pipe sizes (in) used across plumbing parts. */
export const STANDARD_PIPE_SIZES = [0.5, 0.75, 1, 1.25, 1.5, 2, 3, 4] as const;
export const DEFAULT_PIPE_SIZE = 1;
/** Visual radius (ft) for a pipe of `d` inches diameter. */
export const pipeRadius = (d: number) => 0.05 + d * 0.055;
const deg = (d: number) => (d * Math.PI) / 180;
/** Connector layout for an elbow with a configurable sweep angle. */
function elbowConnectors(params: Record<string, number>): ConnectorDef[] {
const B = ELBOW_BEND_RADIUS;
// Arc starts at local origin pointing -X and sweeps toward +Y.
const t = deg(params.angle ?? 90) - Math.PI / 2;
return [
{ id: 'a', pos: [0, 0, 0], dir: [-1, 0, 0] },
{
id: 'b',
pos: [B * Math.cos(t), B * Math.sin(t) + B, 0],
dir: [-Math.sin(t), Math.cos(t), 0],
},
];
}
export function snapPipeSize(value: number): number {
let best: number = STANDARD_PIPE_SIZES[0];
let bestDist = Math.abs(value - best);
for (const size of STANDARD_PIPE_SIZES) {
const dist = Math.abs(value - size);
if (dist < bestDist) {
best = size;
bestDist = dist;
}
}
return best;
}
export const PART_DEFS: Record<PartType, PartDefinition> = {
pipe: {
type: 'pipe',
label: 'Pipe',
category: 'Plumbing',
description: 'Straight pipe segment',
color: '#b8bfc9',
defaults: { length: 2, diameter: DEFAULT_PIPE_SIZE },
params: {
length: { label: 'Length', min: 0.5, max: 12, step: 0.5, unit: 'ft' },
diameter: { label: 'Diameter', min: 0.5, max: 4, step: 0.25, unit: 'in' },
},
getConnectors: (p) => [
{ id: 'a', pos: [-p.length / 2, 0, 0], dir: [-1, 0, 0] },
{ id: 'b', pos: [p.length / 2, 0, 0], dir: [1, 0, 0] },
],
},
coupling: {
type: 'coupling',
label: 'Coupling',
category: 'Plumbing',
description: 'Straight fitting for joining two pipe ends',
color: '#aeb7c2',
defaults: { diameter: DEFAULT_PIPE_SIZE },
params: {
diameter: { label: 'Diameter', min: 0.5, max: 4, step: 0.25, unit: 'in' },
},
getConnectors: () => [
{ id: 'a', pos: [-0.3, 0, 0], dir: [-1, 0, 0] },
{ id: 'b', pos: [0.3, 0, 0], dir: [1, 0, 0] },
],
},
reducer: {
type: 'reducer',
label: 'Reducer',
category: 'Plumbing',
description: 'Inline reducer fitting between two pipe diameters',
color: '#a8b2bf',
defaults: { diameterA: 1.5, diameterB: DEFAULT_PIPE_SIZE },
params: {
diameterA: { label: 'Diameter A', min: 0.5, max: 4, step: 0.25, unit: 'in' },
diameterB: { label: 'Diameter B', min: 0.5, max: 4, step: 0.25, unit: 'in' },
},
getConnectors: () => [
{ id: 'a', pos: [-0.35, 0, 0], dir: [-1, 0, 0] },
{ id: 'b', pos: [0.35, 0, 0], dir: [1, 0, 0] },
],
},
cap: {
type: 'cap',
label: 'Cap',
category: 'Plumbing',
description: 'End cap that terminates a pipe run',
color: '#9aa3b0',
defaults: { diameter: DEFAULT_PIPE_SIZE },
params: {
diameter: { label: 'Diameter', min: 0.5, max: 4, step: 0.25, unit: 'in' },
},
getConnectors: () => [{ id: 'in', pos: [-0.22, 0, 0], dir: [-1, 0, 0] }],
},
elbow: {
type: 'elbow',
label: 'Elbow',
category: 'Plumbing',
description: 'Bend fitting (15180°)',
color: '#9aa3b0',
defaults: { angle: 90, diameter: DEFAULT_PIPE_SIZE },
params: {
angle: { label: 'Bend angle', min: 15, max: 180, step: 15, unit: '°' },
diameter: { label: 'Diameter', min: 0.5, max: 4, step: 0.25, unit: 'in' },
},
getConnectors: elbowConnectors,
},
tee: {
type: 'tee',
label: 'T-Joint',
category: 'Plumbing',
description: '3-way splitter fitting',
color: '#9aa3b0',
defaults: { diameter: DEFAULT_PIPE_SIZE },
params: {
diameter: { label: 'Diameter', min: 0.5, max: 4, step: 0.25, unit: 'in' },
},
getConnectors: () => [
{ id: 'a', pos: [-0.6, 0, 0], dir: [-1, 0, 0] },
{ id: 'b', pos: [0.6, 0, 0], dir: [1, 0, 0] },
{ id: 'c', pos: [0, 0, 0.6], dir: [0, 0, 1] },
],
},
valve: {
type: 'valve',
label: 'Valve',
category: 'Flow Control',
description: 'Inline ball valve (0100% open)',
color: '#e0564f',
defaults: { diameter: DEFAULT_PIPE_SIZE, open: 100 },
params: {
open: { label: 'Open', min: 0, max: 100, step: 5, unit: '%' },
diameter: { label: 'Diameter', min: 0.5, max: 4, step: 0.25, unit: 'in' },
},
getConnectors: () => [
{ id: 'a', pos: [-0.35, 0, 0], dir: [-1, 0, 0] },
{ id: 'b', pos: [0.35, 0, 0], dir: [1, 0, 0] },
],
},
pump: {
type: 'pump',
label: 'Pump',
category: 'Flow Control',
description: 'Submersible/inline pump — pressure source',
color: '#3d7bfa',
// `on` (0|1) is intentionally NOT in params meta — the inspector renders a
// custom power toggle for it. The simulator treats on === 0 as "switched off".
defaults: { gph: 400, maxHeadFt: 8, on: 1 },
params: {
gph: { label: 'Rated flow', min: 50, max: 2000, step: 25, unit: 'GPH' },
maxHeadFt: { label: 'Max head', min: 2, max: 30, step: 1, unit: 'ft' },
},
getConnectors: () => [
{ id: 'in', pos: [-0.45, 0.25, 0], dir: [-1, 0, 0] },
{ id: 'out', pos: [0.45, 0.25, 0], dir: [1, 0, 0] },
],
},
reservoir: {
type: 'reservoir',
label: 'Reservoir',
category: 'Flow Control',
description: 'Water tank — source & return sink',
color: '#2a3950',
defaults: { width: 2, depth: 1.4, height: 1, level: 70, ph: 6.0, ec: 1.2 },
params: {
width: { label: 'Width', min: 1, max: 6, step: 0.5, unit: 'ft' },
depth: { label: 'Depth', min: 1, max: 6, step: 0.5, unit: 'ft' },
height: { label: 'Height', min: 0.5, max: 4, step: 0.25, unit: 'ft' },
level: { label: 'Fill level', min: 0, max: 100, step: 5, unit: '%' },
ph: { label: 'Water pH', min: 4.0, max: 8.5, step: 0.1, unit: '' },
ec: { label: 'Water EC', min: 0.0, max: 3.0, step: 0.1, unit: 'mS/cm' },
},
getConnectors: (p) => [
{ id: 'a', pos: [-p.width / 2, 0.25, 0], dir: [-1, 0, 0] },
{ id: 'b', pos: [p.width / 2, 0.25, 0], dir: [1, 0, 0] },
{ id: 'c', pos: [0, 0.25, -p.depth / 2], dir: [0, 0, -1] },
{ id: 'd', pos: [0, 0.25, p.depth / 2], dir: [0, 0, 1] },
],
},
tower: {
type: 'tower',
label: 'Grow Tower',
category: 'Growing',
description: 'Vertical NFT tower with plant sites',
color: '#e8eaed',
defaults: { height: 4, sites: 6 },
params: {
height: { label: 'Height', min: 2, max: 10, step: 0.5, unit: 'ft' },
sites: { label: 'Plant sites', min: 2, max: 16, step: 1 },
},
getConnectors: (p) => [
{ id: 'in', pos: [0, p.height, 0], dir: [0, 1, 0] },
{ id: 'out', pos: [0.45, 0.15, 0], dir: [1, 0, 0] },
],
},
tray: {
type: 'tray',
label: 'Grow Tray',
category: 'Growing',
description: 'Flood/NFT channel tray',
color: '#d7dce2',
defaults: { length: 3, width: 1.2 },
params: {
length: { label: 'Length', min: 1, max: 8, step: 0.5, unit: 'ft' },
width: { label: 'Width', min: 0.5, max: 4, step: 0.25, unit: 'ft' },
},
getConnectors: (p) => [
{ id: 'in', pos: [-p.length / 2, 0.15, 0], dir: [-1, 0, 0] },
{ id: 'out', pos: [p.length / 2, 0.15, 0], dir: [1, 0, 0] },
],
},
wallPanel: {
type: 'wallPanel',
label: 'Wall Panel',
category: 'Growing',
description: 'Vertical grow wall with pockets',
color: '#3b7a57',
defaults: { width: 3, height: 4, rows: 4, cols: 5 },
params: {
width: { label: 'Width', min: 1, max: 8, step: 0.5, unit: 'ft' },
height: { label: 'Height', min: 1, max: 8, step: 0.5, unit: 'ft' },
rows: { label: 'Pocket rows', min: 1, max: 8, step: 1 },
cols: { label: 'Pocket cols', min: 1, max: 10, step: 1 },
},
getConnectors: (p) => [
{ id: 'in', pos: [0, p.height, 0], dir: [0, 1, 0] },
{ id: 'out', pos: [0, 0.05, 0], dir: [0, -1, 0] },
],
},
lattice: {
type: 'lattice',
label: 'Lattice',
category: 'Structure',
description: 'Structural lattice frame (no flow)',
color: '#8a7a5c',
defaults: { width: 2.5, height: 3 },
params: {
width: { label: 'Width', min: 1, max: 8, step: 0.5, unit: 'ft' },
height: { label: 'Height', min: 1, max: 8, step: 0.5, unit: 'ft' },
},
getConnectors: () => [],
},
netPot: {
type: 'netPot',
label: 'Net Pot',
category: 'Growing',
description: 'Plant cup with seedling (decorative)',
color: '#222a33',
defaults: { size: 1 },
params: {
size: { label: 'Size', min: 0.5, max: 2, step: 0.25 },
},
getConnectors: () => [],
},
emitter: {
type: 'emitter',
label: 'Emitter',
category: 'Flow Control',
description: 'Drip/spray outlet — terminates a line',
color: '#46c0e8',
defaults: {},
params: {},
getConnectors: () => [{ id: 'in', pos: [0.2, 0, 0], dir: [1, 0, 0] }],
},
drain: {
type: 'drain',
label: 'Drain',
category: 'Flow Control',
description: 'Floor drain — limited-capacity exit point',
color: '#64748b',
defaults: { capacityGph: 250 },
params: {
capacityGph: { label: 'Capacity', min: 50, max: 2000, step: 25, unit: 'GPH' },
},
getConnectors: () => [{ id: 'in', pos: [-0.35, 0.25, 0], dir: [-1, 0, 0] }],
},
growTent: {
type: 'growTent',
label: 'Grow Tent',
category: 'Environment',
description: 'Room/tent enclosure for planning air exchange and equipment',
color: '#475569',
defaults: { width: 7, depth: 5, height: 4, sealed: 0 },
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 },
},
getConnectors: () => [],
},
inlineFan: {
type: 'inlineFan',
label: 'Inline Fan',
category: 'Environment',
description: 'Duct fan for room exhaust/intake calculations',
color: '#0f766e',
defaults: { cfm: 600, diameter: 6, exhaust: 1 },
params: {
cfm: { label: 'Airflow', min: 50, max: 4000, step: 25, unit: 'CFM' },
diameter: { label: 'Duct size', min: 4, max: 14, step: 2, unit: 'in' },
exhaust: { label: 'Exhaust mode', min: 0, max: 1, step: 1 },
},
getConnectors: () => [],
},
circulationFan: {
type: 'circulationFan',
label: 'Circulation Fan',
category: 'Environment',
description: 'Internal airflow fan for canopy mixing',
color: '#2563eb',
defaults: { cfm: 250, sweep: 90 },
params: {
cfm: { label: 'Airflow', min: 50, max: 1500, step: 25, unit: 'CFM' },
sweep: { label: 'Sweep', min: 0, max: 180, step: 15, unit: '°' },
},
getConnectors: () => [],
},
growLight: {
type: 'growLight',
label: 'Grow Light',
category: 'Environment',
description: 'Overhead grow fixture for room planning',
color: '#fbbf24',
defaults: { width: 2, depth: 2, watts: 650, hangingHeight: 1.5, photoperiod: 18 },
params: {
width: { label: 'Width', min: 0.5, max: 6, step: 0.25, unit: 'ft' },
depth: { label: 'Depth', min: 0.5, max: 6, step: 0.25, unit: 'ft' },
watts: { label: 'Power', min: 50, max: 1500, step: 25, unit: 'W' },
hangingHeight: { label: 'Hang height', min: 0.25, max: 6, step: 0.25, unit: 'ft' },
photoperiod: { label: 'Hours On', min: 0, max: 24, step: 1, unit: 'h' },
},
getConnectors: () => [],
},
co2Tank: {
type: 'co2Tank',
label: 'CO₂ Tank',
category: 'Environment',
description: 'CO₂ cylinder for sealed-room enrichment planning',
color: '#22c55e',
defaults: { pounds: 20, regulatorCfh: 20 },
params: {
pounds: { label: 'Tank size', min: 5, max: 100, step: 5, unit: 'lb' },
regulatorCfh: { label: 'Release rate', min: 1, max: 100, step: 1, unit: 'CFH' },
},
getConnectors: () => [],
},
airCompressor: {
type: 'airCompressor',
label: 'Air Compressor',
category: 'Flow Control',
description: 'Aerate reservoir water with an attached airstone',
color: '#0ea5e9',
defaults: { gph: 120, frequency: 5, on: 1 },
params: {
gph: { label: 'Air Flow', min: 20, max: 400, step: 10, unit: 'GPH' },
frequency: { label: 'Bubble Freq', min: 1, max: 10, step: 1, unit: 'Hz' },
},
getConnectors: () => [],
},
reducingTee: {
type: 'reducingTee',
label: 'Reducing Tee',
category: 'Plumbing',
description: 'Tee branch with a reduced secondary outlet size',
color: '#8b9bb4',
defaults: { diameterA: 1.5, diameterB: 1.0 },
params: {
diameterA: { label: 'Main Run Diameter', min: 0.5, max: 4, step: 0.25, unit: 'in' },
diameterB: { label: 'Branch Diameter', min: 0.5, max: 4, step: 0.25, unit: 'in' },
},
getConnectors: () => [
{ id: 'a', pos: [-0.6, 0, 0], dir: [-1, 0, 0] },
{ id: 'b', pos: [0.6, 0, 0], dir: [1, 0, 0] },
{ id: 'c', pos: [0, 0, 0.6], dir: [0, 0, 1] },
],
},
reducingElbow: {
type: 'reducingElbow',
label: 'Reducing Elbow',
category: 'Plumbing',
description: '90-degree elbow transitioning between two diameters',
color: '#9aa3b0',
defaults: { diameterA: 1.5, diameterB: 1.0 },
params: {
diameterA: { label: 'Diameter A', min: 0.5, max: 4, step: 0.25, unit: 'in' },
diameterB: { label: 'Diameter B', min: 0.5, max: 4, step: 0.25, unit: 'in' },
},
getConnectors: () => [
{ id: 'a', pos: [0, 0, 0], dir: [-1, 0, 0] },
{ id: 'b', pos: [0.18, 0.18, 0], dir: [0, 1, 0] },
],
},
bulkhead: {
type: 'bulkhead',
label: 'Bulkhead Fitting',
category: 'Plumbing',
description: 'Tank wall pass-through connector',
color: '#27272a',
defaults: { diameter: 1.0 },
params: {
diameter: { label: 'Diameter', min: 0.5, max: 4, step: 0.25, unit: 'in' },
},
getConnectors: () => [
{ id: 'a', pos: [-0.2, 0, 0], dir: [-1, 0, 0] },
{ id: 'b', pos: [0.2, 0, 0], dir: [1, 0, 0] },
],
},
waterChiller: {
type: 'waterChiller',
label: 'Water Chiller',
category: 'Flow Control',
description: 'Thermoelectric water chiller to keep reservoirs cool',
color: '#0891b2',
defaults: { targetTemp: 68, on: 1, diameter: 1.0 },
params: {
targetTemp: { label: 'Target Temp', min: 45, max: 80, step: 1, unit: '°F' },
diameter: { label: 'Port Size', min: 0.5, max: 2.0, step: 0.25, unit: 'in' },
},
getConnectors: () => [
{ id: 'in', pos: [-0.2, 1.05, 0], dir: [0, 1, 0] },
{ id: 'out', pos: [0.2, 1.05, 0], dir: [0, 1, 0] },
],
},
reverseOsmosis: {
type: 'reverseOsmosis',
label: 'RO Filter',
category: 'Plumbing',
description: 'Reverse osmosis filter to purify source water',
color: '#3b82f6',
defaults: { diameter: 1.0 },
params: {
diameter: { label: 'Diameter', min: 0.5, max: 4, step: 0.25, unit: 'in' },
},
getConnectors: () => [
{ id: 'a', pos: [-0.3, 0, 0], dir: [-1, 0, 0] },
{ id: 'b', pos: [0.3, 0, 0], dir: [1, 0, 0] },
],
},
};
export const CATEGORIES: { name: string; types: PartType[] }[] = [
{ name: 'Plumbing', types: ['pipe', 'coupling', 'reducer', 'cap', 'elbow', 'tee', 'reducingTee', 'reducingElbow', 'bulkhead', 'reverseOsmosis'] },
{ name: 'Flow Control', types: ['pump', 'reservoir', 'valve', 'emitter', 'drain', 'airCompressor', 'waterChiller'] },
{ name: 'Growing', types: ['tower', 'tray', 'wallPanel', 'netPot'] },
{ name: 'Structure', types: ['lattice'] },
{ name: 'Environment', types: ['growTent', 'inlineFan', 'circulationFan', 'growLight', 'co2Tank'] },
];
/** Parts that carry water (participate in the flow graph). */
export const FLOW_PARTS = new Set<PartType>([
'pipe', 'coupling', 'reducer', 'cap', 'elbow', 'tee', 'valve', 'pump', 'reservoir', 'tower', 'tray', 'wallPanel', 'emitter', 'drain',
'reducingTee', 'reducingElbow', 'bulkhead', 'waterChiller', 'reverseOsmosis',
]);
/** Plumbing parts whose unconnected ends count as leaks. */
export const OPEN_END_WARN_PARTS = new Set<PartType>([
'pipe', 'coupling', 'reducer', 'elbow', 'tee', 'valve', 'pump',
'reducingTee', 'reducingElbow', 'bulkhead', 'waterChiller', 'reverseOsmosis',
]);

19
src/parts/hotbar.ts Normal file
View File

@@ -0,0 +1,19 @@
import type { PartType } from '../types';
/** Default builder-first hotbar order for fast keyboard placement. */
export const HOTBAR_PARTS: PartType[] = [
'pipe',
'elbow',
'tee',
'valve',
'pump',
'reservoir',
'tower',
'tray',
'emitter',
];
export function getHotbarPartForKey(key: string): PartType | null {
const idx = Number(key) - 1;
return Number.isInteger(idx) && idx >= 0 && idx < HOTBAR_PARTS.length ? HOTBAR_PARTS[idx] : null;
}

View File

@@ -0,0 +1,114 @@
import React, { useState } from 'react';
import { useAuth } from '../auth/AuthContext';
interface PaywallModalProps {
onClose: () => void;
}
export const PaywallModal: React.FC<PaywallModalProps> = ({ onClose }) => {
const { token, user } = useAuth();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handlePurchase = async () => {
if (!token) return;
setLoading(true);
setError(null);
try {
const response = await fetch('/api/payments/create-checkout-session', {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`
}
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Failed to create payment session');
}
if (data.url) {
window.location.href = data.url;
} else {
throw new Error('No checkout URL received');
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Something went wrong');
setLoading(false);
}
};
return (
<div className="fixed inset-0 z-[120] flex items-center justify-center bg-zinc-950/80 backdrop-blur-sm">
<div className="w-full max-w-md overflow-hidden rounded-2xl border border-zinc-850 bg-zinc-900 shadow-2xl">
{/* Decorative Top Banner */}
<div className="h-2 bg-gradient-to-r from-sky-400 via-blue-500 to-indigo-600" />
<div className="p-8">
<div className="flex flex-col items-center text-center">
{/* Crown Icon / Visual Asset */}
<div className="mb-4 grid h-14 w-14 place-items-center rounded-full bg-sky-500/10 text-2xl text-sky-400">
💎
</div>
<h3 className="text-xl font-bold text-zinc-100">
Unlock Premium Features
</h3>
<p className="mt-2 text-xs text-zinc-400 leading-relaxed px-2">
Free profiles do not have auto-saves or cloud build spaces. Upgrade to Premium permanently to enable browser auto-saves and unlock 10 cloud project slots.
</p>
</div>
{/* Pricing Card */}
<div className="my-6 rounded-xl border border-zinc-800 bg-zinc-950/50 p-5 text-center">
<span className="text-xs font-semibold text-sky-400 uppercase tracking-wider">
Premium Lifetime Upgrade
</span>
<div className="mt-2 flex items-baseline justify-center gap-1">
<span className="text-3xl font-black text-zinc-100">$5</span>
<span className="text-xs text-zinc-500">one-time purchase</span>
</div>
<div className="mt-4 flex flex-col gap-1.5 text-left text-xs text-zinc-400 border-t border-zinc-850 pt-4">
<div className="flex justify-between">
<span>Slots Owned:</span>
<span className="font-semibold text-zinc-200">{user && user.purchased_slots > 0 ? 10 : 0}</span>
</div>
<div className="flex justify-between">
<span>Active Projects:</span>
<span className="font-semibold text-zinc-200">{user?.project_count ?? 0}</span>
</div>
</div>
</div>
{error && (
<div className="mb-4 rounded-lg border border-red-500/15 bg-red-500/5 p-3 text-xs text-red-400">
{error}
</div>
)}
<div className="flex flex-col gap-3">
{user && (
<div className="w-full flex justify-center py-1">
<stripe-buy-button
buy-button-id="buy_btn_1ThubIDPQ7Y9c4bKgoRwZuIm"
publishable-key="pk_live_51ThuN5DPQ7Y9c4bKvvcy6bl6FuoQufKFWNYdkaXwINzWiEqyPlrU2WwO2mlCpKtuGXkBfKfozBVfT0aTBTU0TdLm00LIvAP3si"
client-reference-id={String(user.id)}
/>
</div>
)}
<button
onClick={onClose}
className="w-full rounded-lg bg-zinc-800/60 py-2.5 text-xs font-medium text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200 transition cursor-pointer"
>
Cancel
</button>
</div>
</div>
</div>
</div>
);
};

63
src/routing/types.ts Normal file
View File

@@ -0,0 +1,63 @@
import type { PlacedPart, Vec3 } from '../types';
/** Axis-aligned bounding box in world space (ft). */
export interface Aabb {
min: Vec3;
max: Vec3;
}
/**
* A port to route from/to: world position of the connector and its
* outward-facing direction (the direction a mating pipe would extend).
* Matches the pos/dir of a `WorldConnector` from `getWorldConnectors`.
*/
export interface PortRef {
pos: Vec3;
dir: Vec3;
}
/** Static description of the scene the router must avoid / stay within. */
export interface RouteWorld {
/**
* World AABBs of existing parts (see `partsToObstacles`). The caller should
* EXCLUDE the two parts being routed from/to; as a safety net, obstacles
* whose (clearance-inflated) box contains a port are ignored for the
* stub leg leaving/entering that port.
*/
obstacles: Aabb[];
/**
* Search volume. Default: bounding box of start/end/obstacles padded by
* 4 ft, with min.y clamped to 0 (routes never dip below the floor).
*/
bounds?: Aabb;
/**
* Lattice spacing (ft) for the A* search. Default 0.5.
* Endpoints are ALWAYS refined exactly (their coordinates are injected
* into the lattice), so a coarse step does not hurt mating precision —
* it only limits where intermediate bends may sit. 0.25 quadruples the
* per-axis resolution (~8x the node count); see search.ts for the budget.
*/
gridStep?: number;
/** Minimum distance (ft) kept between pipe centerlines and obstacle AABBs. Default 0.3. */
clearance?: number;
/** Pipe diameter (inches) used for generated parts and collision radius. Default 1. */
diameter?: number;
}
export type RouteProfile = 'shortest' | 'fewestBends' | 'cleanest';
export interface RouteResult {
/** Simplified axis-aligned polyline: [startPort, corners..., endPort]. */
waypoints: Vec3[];
/** Exactly-mated pipes + 90° elbows realizing the polyline. */
parts: PlacedPart[];
/** Total centerline length of the run (ft). */
lengthFt: number;
/** Number of 90° bends. */
bends: number;
ok: boolean;
/** Human-readable failure description when ok is false. */
reason?: string;
/** Which profile produced this result. */
profile?: RouteProfile;
}

70
src/routing/vec.ts Normal file
View File

@@ -0,0 +1,70 @@
import type { Vec3 } from '../types';
/** Tolerance used for geometric comparisons throughout the router (ft). */
export const EPS = 1e-6;
export const add = (a: Vec3, b: Vec3): Vec3 => [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
export const sub = (a: Vec3, b: Vec3): Vec3 => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
export const scale = (a: Vec3, s: number): Vec3 => [a[0] * s, a[1] * s, a[2] * s];
export const dot = (a: Vec3, b: Vec3) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
export const cross = (a: Vec3, b: Vec3): Vec3 => [
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0],
];
export const length = (a: Vec3) => Math.hypot(a[0], a[1], a[2]);
export const dist = (a: Vec3, b: Vec3) => Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
export const manhattan = (a: Vec3, b: Vec3) =>
Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]) + Math.abs(a[2] - b[2]);
export const vecEquals = (a: Vec3, b: Vec3, eps = EPS) =>
Math.abs(a[0] - b[0]) < eps && Math.abs(a[1] - b[1]) < eps && Math.abs(a[2] - b[2]) < eps;
/**
* The six axis-aligned unit directions. Index layout: even = positive,
* odd = negative, so `idx >> 1` is the axis (0=X 1=Y 2=Z) and `idx ^ 1`
* is the reversed direction.
*/
export const AXIS_DIRS: readonly Vec3[] = [
[1, 0, 0],
[-1, 0, 0],
[0, 1, 0],
[0, -1, 0],
[0, 0, 1],
[0, 0, -1],
];
export const axisOfDir = (dirIdx: number) => dirIdx >> 1;
export const signOfDir = (dirIdx: number) => (dirIdx & 1 ? -1 : 1);
export const reverseDir = (dirIdx: number) => dirIdx ^ 1;
/** Index into AXIS_DIRS for an (exactly) axis-aligned unit vector, or -1. */
export function dirIndexOf(v: Vec3, eps = EPS): number {
for (let i = 0; i < 6; i++) {
const d = AXIS_DIRS[i];
if (Math.abs(v[0] - d[0]) < eps && Math.abs(v[1] - d[1]) < eps && Math.abs(v[2] - d[2]) < eps) {
return i;
}
}
return -1;
}
/**
* Snap a roughly axis-aligned direction to the nearest exact axis direction.
* Returns null when the vector is degenerate or too far from any axis
* (more than ~25° off).
*/
export function snapAxisDir(v: Vec3): Vec3 | null {
const l = length(v);
if (l < EPS) return null;
const n: Vec3 = [v[0] / l, v[1] / l, v[2] / l];
let best = -1;
let bestDot = 0.9; // cos(~25°) — refuse wildly diagonal directions
for (let i = 0; i < 6; i++) {
const d = dot(n, AXIS_DIRS[i]);
if (d > bestDot) {
bestDot = d;
best = i;
}
}
return best >= 0 ? ([...AXIS_DIRS[best]] as Vec3) : null;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,66 @@
import type { FillState } from '../types';
/**
* waterState — tiny module-level reactive store for per-part water visuals.
*
* The WaterDriver (mounted by FlowArrows) advances a global fill wavefront
* every frame and writes each part's current fill fraction / state here.
* Mesh components in PartBody read it inside their own useFrame and mutate
* materials directly — no React state, no zustand, no re-renders.
*/
export interface WaterVisual {
/** 0 (empty) → 1 (full of water). */
fill: number;
/** GPH currently flowing through the part. */
flow: number;
/** Line is backing up (undersized drain / dead end). */
backedUp: boolean;
/** Drain receiving more than its capacity — spilling over. */
overflow: boolean;
/** Net pot spilling from the rim. */
spill: boolean;
state: FillState;
/** Connector id where water enters (orients the fill direction). */
entry?: string;
}
export const waterVisuals = new Map<string, WaterVisual>();
export function getWaterVisual(partId: string): WaterVisual | undefined {
return waterVisuals.get(partId);
}
// ---------------------------------------------------------------------------
// Water clock — the time base for ALL water animation (ripples, arrows,
// texture scroll, glow pulses). The WaterDriver advances it only while
// `simRunning` is true, so pausing freezes every water visual in place.
// ---------------------------------------------------------------------------
let waterTime = 0;
let waterDt = 0;
/** Advance the clock by one (running) frame. */
export function tickWaterClock(dt: number): void {
waterDt = dt;
waterTime += dt;
}
/** Mark the current frame as paused (freeze-frame). */
export function freezeWaterClock(): void {
waterDt = 0;
}
/** Accumulated animation time (seconds). Frozen while paused. */
export const getWaterTime = (): number => waterTime;
/** This frame's animation delta (0 while paused). */
export const getWaterDt = (): number => waterDt;
export function ensureWaterVisual(partId: string): WaterVisual {
let v = waterVisuals.get(partId);
if (!v) {
v = { fill: 0, flow: 0, backedUp: false, overflow: false, spill: false, state: 'empty' };
waterVisuals.set(partId, v);
}
return v;
}

1164
src/store/builderStore.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,13 @@
import { create } from 'zustand';
export type SaveStatus = 'saved' | 'unsaved' | 'saving';
interface SaveStatusStore {
status: SaveStatus;
setStatus: (status: SaveStatus) => void;
}
export const useSaveStatus = create<SaveStatusStore>((set) => ({
status: 'saved',
setStatus: (status) => set({ status }),
}));

236
src/types.ts Normal file
View File

@@ -0,0 +1,236 @@
/**
* Shared domain types for Hydro Builder.
*
* World units: 1 unit = 1 ft (used for head/elevation math).
* Pipe diameters are expressed in inches, pump flow in GPH.
*/
export type Vec3 = [number, number, number];
export type PartType =
| 'pipe'
| 'coupling'
| 'reducer'
| 'cap'
| 'elbow'
| 'tee'
| 'valve'
| 'pump'
| 'reservoir'
| 'tower'
| 'tray'
| 'wallPanel'
| 'lattice'
| 'netPot'
| 'emitter'
| 'drain'
| 'growTent'
| 'inlineFan'
| 'circulationFan'
| 'growLight'
| 'co2Tank'
| 'airCompressor'
| 'reducingTee'
| 'reducingElbow'
| 'bulkhead'
| 'waterChiller'
| 'reverseOsmosis';
/** A part instance placed in the scene. */
export interface PlacedPart {
id: string;
type: PartType;
/** World position (ft). */
position: Vec3;
/** Euler rotation in radians, XYZ order. */
rotation: Vec3;
/** Type-specific parameters (length, diameter, gph, ...). */
params: Record<string, number>;
/** Optional user-given name shown in the inspector. */
label?: string;
/** Optional custom color (hex) overriding the catalog color. */
color?: string;
/** Room ID of the workspace this part belongs to. */
roomId?: string;
}
/**
* Real-world joint style of a connection point. Validation/UI layers use it
* to flag joints that don't exist at the hardware store (slip↔barb etc.).
*/
export type ConnectionStyle = 'slip' | 'npt' | 'barb';
/** A connection point defined in a part's local space. */
export interface ConnectorDef {
id: string;
pos: Vec3;
/** Outward-facing direction (unit vector, local space). */
dir: Vec3;
/** Physical joint style (slip = PVC cement, npt = threaded, barb = hose). */
style?: ConnectionStyle;
}
/** Metadata used to render a slider/input for one part parameter. */
export interface ParamMeta {
label: string;
min: number;
max: number;
step: number;
unit?: string;
}
export type PartCategory = 'Plumbing' | 'Flow Control' | 'Growing' | 'Structure' | 'Environment';
/** Static definition of a part type (catalog entry). */
export interface PartDefinition {
type: PartType;
label: string;
category: PartCategory;
description: string;
/** Accent color used in the library and as the base mesh color. */
color: string;
defaults: Record<string, number>;
params: Record<string, ParamMeta>;
getConnectors: (params: Record<string, number>) => ConnectorDef[];
}
/** A connector resolved to world space. */
export interface WorldConnector {
key: string; // `${partId}:${connectorId}`
partId: string;
connectorId: string;
partType: PartType;
pos: Vec3;
dir: Vec3;
}
// ---------- Simulation ----------
export type WarningLevel = 'info' | 'warning' | 'error';
export interface SimWarning {
level: WarningLevel;
message: string;
partId?: string;
}
/** Pump duty phase (timer mode). */
export type PumpPhase = 'on' | 'off' | 'continuous';
export interface PumpResult {
pumpId: string;
ratedGph: number;
/** Actual delivered flow after head + resistance losses. */
gph: number;
headFt: number;
maxHeadFt: number;
resistance: number;
/** True when the pump is wired to a reservoir and pushing water. */
running: boolean;
/** Operating point where the pump curve meets the system curve. */
opPoint: { gph: number; headFt: number };
/** Current timer phase ('continuous' when no timer). */
phase: PumpPhase;
/** Reservoir this pump draws from (when wired correctly). */
sourceReservoirId?: string;
}
/** Directed segment carrying flow — used to render animated arrows. */
export interface FlowSegment {
partId: string;
from: Vec3;
to: Vec3;
gph: number;
/** Pipe diameter (in) of the part the segment runs through — drives arrow speed. */
diameter?: number;
}
/** Visual fill state of a flow part (driven over time by the water system). */
export type FillState = 'empty' | 'filling' | 'full' | 'backedUp';
/** Per-drain inflow vs. capacity report (drain / standpipe / bellSiphon). */
export interface DrainStatus {
partId: string;
inflowGph: number;
capacityGph: number;
overflowing: boolean;
}
export interface NetPotStatus {
partId: string;
sourcePartId: string;
inflowGph: number;
rimHeightFt: number;
waterHeightFt: number;
overflowing: boolean;
}
export interface ReservoirRuntimeStatus {
partId: string;
gallonsInitial: number;
gallonsCurrent: number;
gallonsCapacity: number;
netOutflowGph: number;
minutesRemaining: number | null;
empty: boolean;
}
/** Per-vessel inflow/exit bookkeeping (consumed by the water ledger). */
export interface VesselIO {
/** GPH entering the vessel. */
inGph: number;
/** Exit parts (drain family) whose supply chain starts at this vessel. */
exits: { partId: string; type: PartType; capacityGph: number }[];
/** True when water can also leave via reservoir/emitter/open end. */
unlimited: boolean;
}
export interface SimResult {
/** partId -> GPH flowing through that part (0 if none). */
flows: Record<string, number>;
pumps: PumpResult[];
segments: FlowSegment[];
warnings: SimWarning[];
/** Connector keys that are joined to another connector. */
connectedKeys: Set<string>;
connectionCount: number;
totalGph: number;
/** partId -> cumulative water-travel distance from its pump (ft). */
fillDistance: Record<string, number>;
/** partId -> internal water-path length (ft); fill spans [dist, dist+len]. */
fillLength: Record<string, number>;
/** Longest water path in the system (ft) — fill animation end point. */
totalPathLength: number;
/** partId -> connector id through which water enters the part. */
entryConnector: Record<string, string>;
/** Parts suffering backpressure (undersized drain / dead-end branch). */
backedUp: Set<string>;
/** Per-drain inflow vs capacity (for overflow visuals). */
drains: DrainStatus[];
/** Net pots overflowing because plumbing below them is backing up. */
netPots: NetPotStatus[];
/** partId -> water velocity through the part (ft/s, slope-adjusted). */
velocities: Record<string, number>;
/** partId -> rough gauge pressure (psi). */
pressures: Record<string, number>;
/** partId -> immediate upstream part along the supply path. */
upstream: Record<string, string>;
/** vesselId -> inflow/exit bookkeeping for accumulation & overflow. */
vesselIO: Record<string, VesselIO>;
/** Runtime drawdown state for finite reservoirs. */
reservoirRuntime: Record<string, ReservoirRuntimeStatus>;
}
// ---------- Persistence ----------
export interface ProjectFile {
app: 'hydro-builder';
version: 1;
name: string;
savedAt: string;
parts: PlacedPart[];
}
export type ViewMode = 'orbit' | 'top' | 'front' | 'side';
export type RenderMode = 'builder' | 'xray' | 'water';
export type Tool = 'select' | 'measure' | 'pipeRun';

89
src/utils/cadExporter.ts Normal file
View File

@@ -0,0 +1,89 @@
import * as THREE from 'three';
import { GLTFExporter } from 'three/examples/jsm/exporters/GLTFExporter.js';
import { OBJExporter } from 'three/examples/jsm/exporters/OBJExporter.js';
/**
* Traverses the scene, clones all physical part geometries (where name is 'part-body-mesh'),
* applies their world transformations, and returns a single group containing them.
* This filters out all UI guides, grids, handles, and water visuals (non-castShadow meshes).
*/
export function getExportGroup(scene: THREE.Scene): THREE.Group {
const exportGroup = new THREE.Group();
exportGroup.name = 'ExportedParts';
scene.traverse((child) => {
if (child.name === 'part-body-mesh') {
// Clone the part's visual hierarchy
const clone = child.clone(true);
// Filter out non-castShadow meshes (which removes water visuals, etc.)
const toRemove: THREE.Object3D[] = [];
clone.traverse((o) => {
if (o instanceof THREE.Mesh && !o.castShadow) {
toRemove.push(o);
}
});
for (const o of toRemove) {
o.parent?.remove(o);
}
// Compute and apply the world transform of the original part to the clone
child.updateWorldMatrix(true, false);
// Reset the clone's local transform so applyMatrix4 doesn't double-apply it
clone.position.set(0, 0, 0);
clone.rotation.set(0, 0, 0);
clone.scale.set(1, 1, 1);
clone.updateMatrix();
clone.applyMatrix4(child.matrixWorld);
exportGroup.add(clone);
}
});
return exportGroup;
}
/** Helper to trigger a browser file download */
function downloadFile(content: BlobPart, mimeType: string, filename: string) {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
/** Export the scene's parts as a glTF JSON (.gltf) file */
export function exportToGLTF(scene: THREE.Scene, projectName: string) {
const exportGroup = getExportGroup(scene);
const exporter = new GLTFExporter();
exporter.parse(
exportGroup,
(gltf) => {
const output = typeof gltf === 'string' ? gltf : JSON.stringify(gltf, null, 2);
const filename = `${projectName.replace(/\s+/g, '_') || 'hydro_system'}.gltf`;
downloadFile(output, 'application/json', filename);
},
(error) => {
console.error('Failed to export glTF:', error);
},
{
binary: false,
}
);
}
/** Export the scene's parts as an OBJ (.obj) file */
export function exportToOBJ(scene: THREE.Scene, projectName: string) {
const exportGroup = getExportGroup(scene);
const exporter = new OBJExporter();
const objText = exporter.parse(exportGroup);
const filename = `${projectName.replace(/\s+/g, '_') || 'hydro_system'}.obj`;
downloadFile(objText, 'text/plain', filename);
}

176
src/utils/connectors.ts Normal file
View File

@@ -0,0 +1,176 @@
import * as THREE from 'three';
import type { PlacedPart, Vec3, WorldConnector } from '../types';
import { PART_DEFS } from '../parts/catalog';
/** Two connectors closer than this (ft) are considered joined. */
export const CONNECT_DIST = 0.35;
/** Drop a part within this distance of a mating connector and it snaps. */
export const SNAP_DIST = 0.8;
const _e = new THREE.Euler();
const _v = new THREE.Vector3();
/** Resolve a part's connectors into world space. */
export function getWorldConnectors(part: PlacedPart): WorldConnector[] {
const def = PART_DEFS[part.type];
_e.set(part.rotation[0], part.rotation[1], part.rotation[2], 'XYZ');
return def.getConnectors(part.params).map((c) => {
const pos = _v.set(...c.pos).applyEuler(_e).toArray() as Vec3;
pos[0] += part.position[0];
pos[1] += part.position[1];
pos[2] += part.position[2];
const dir = new THREE.Vector3(...c.dir).applyEuler(_e).toArray() as Vec3;
return { key: `${part.id}:${c.id}`, partId: part.id, connectorId: c.id, partType: part.type, pos, dir };
});
}
export const dist3 = (a: Vec3, b: Vec3) =>
Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
export interface PipeTapTarget {
partId: string;
pos: Vec3;
branchPos: Vec3;
rotationY: number;
diameter: number;
d: number;
}
/**
* Find the translation that snaps `part` onto the nearest open connector of
* any other part (within SNAP_DIST). Returns null when nothing is in range.
*/
export function findSnapDelta(
part: PlacedPart,
others: PlacedPart[],
): Vec3 | null {
const mine = getWorldConnectors(part);
if (!mine.length) return null;
let best: { d: number; delta: Vec3 } | null = null;
for (const other of others) {
if (other.id === part.id) continue;
for (const oc of getWorldConnectors(other)) {
for (const mc of mine) {
const d = dist3(mc.pos, oc.pos);
if (d < SNAP_DIST && (!best || d < best.d)) {
best = {
d,
delta: [oc.pos[0] - mc.pos[0], oc.pos[1] - mc.pos[1], oc.pos[2] - mc.pos[2]],
};
}
}
}
}
// Already perfectly seated — no need to move.
if (best && best.d < 1e-4) return null;
return best ? best.delta : null;
}
/**
* Find the nearest *open* connector to `point` among `parts`, ignoring any
* part in `excludeIds` (the group being dragged). A connector is open when no
* other (non-excluded) connector sits within CONNECT_DIST of it.
* Used to live-highlight + magnetically snap connector-handle drags.
*/
export function findNearestOpenConnector(
point: Vec3,
parts: PlacedPart[],
excludeIds: ReadonlySet<string>,
maxDist = SNAP_DIST,
): (WorldConnector & { d: number }) | null {
const candidates: WorldConnector[] = [];
for (const p of parts) {
if (excludeIds.has(p.id)) continue;
candidates.push(...getWorldConnectors(p));
}
const occupied = new Set<string>();
for (let i = 0; i < candidates.length; i++) {
for (let j = i + 1; j < candidates.length; j++) {
if (candidates[i].partId === candidates[j].partId) continue;
if (dist3(candidates[i].pos, candidates[j].pos) < CONNECT_DIST) {
occupied.add(candidates[i].key);
occupied.add(candidates[j].key);
}
}
}
let best: (WorldConnector & { d: number }) | null = null;
for (const c of candidates) {
if (occupied.has(c.key)) continue;
const d = dist3(point, c.pos);
if (d < maxDist && (!best || d < best.d)) best = { ...c, d };
}
return best;
}
/**
* Find a branchable point along the body of an existing straight pipe.
* Used by Pipe Run to insert a tee and branch from the side of a line.
*/
export function findNearestPipeTap(
point: Vec3,
parts: PlacedPart[],
maxDist = SNAP_DIST,
): PipeTapTarget | null {
let best: PipeTapTarget | null = null;
for (const part of parts) {
if (part.type !== 'pipe') continue;
const conns = getWorldConnectors(part);
const a = conns.find((c) => c.connectorId === 'a');
const b = conns.find((c) => c.connectorId === 'b');
if (!a || !b) continue;
const ax = a.pos[0];
const az = a.pos[2];
const bx = b.pos[0];
const bz = b.pos[2];
const vx = bx - ax;
const vz = bz - az;
const len2 = vx * vx + vz * vz;
if (len2 < 1e-6) continue;
const wx = point[0] - ax;
const wz = point[2] - az;
const t = (wx * vx + wz * vz) / len2;
if (t <= 0.15 || t >= 0.85) continue;
const px = ax + vx * t;
const pz = az + vz * t;
const d = Math.hypot(point[0] - px, point[2] - pz);
if (d > maxDist) continue;
const baseYaw = part.rotation[1];
const dx = point[0] - px;
const dz = point[2] - pz;
const angleToPoint = Math.atan2(-dz, dx);
const relativeAngle = angleToPoint - baseYaw;
const roundedRelativeAngle = Math.round(relativeAngle / (Math.PI / 2)) * (Math.PI / 2);
let rotationY = baseYaw + roundedRelativeAngle + Math.PI / 2;
rotationY = Math.atan2(Math.sin(rotationY), Math.cos(rotationY));
const tee: PlacedPart = {
id: '__tap__',
type: 'tee',
position: [px, part.position[1], pz],
rotation: [0, rotationY, 0],
params: { ...PART_DEFS.tee.defaults, diameter: part.params.diameter ?? 1 },
};
const branch = getWorldConnectors(tee).find((c) => c.connectorId === 'c');
if (!branch) continue;
const candidate: PipeTapTarget = {
partId: part.id,
pos: [px, part.position[1], pz],
branchPos: branch.pos,
rotationY,
diameter: part.params.diameter ?? 1,
d,
};
if (!best || d < best.d) best = candidate;
}
return best;
}

514
src/utils/demoProject.ts Normal file
View File

@@ -0,0 +1,514 @@
import type { PlacedPart, Vec3 } from '../types';
export type QuickBuildId = 'nftLoop' | 'dripManifold' | 'dwcSystem' | 'finiteDrainTest';
export const QUICK_BUILDS: { id: QuickBuildId; label: string; description: string }[] = [
{
id: 'nftLoop',
label: 'NFT loop',
description: 'Reservoir, pump, NFT tray, return drain, and plant cups',
},
{
id: 'dripManifold',
label: 'Drip manifold',
description: 'Pump-fed header with tee branches, valves, and emitters',
},
{
id: 'dwcSystem',
label: 'DWC bucket system',
description: 'Deep Water Culture bucket with aerator, airstone, net pots, and water chiller',
},
{
id: 'finiteDrainTest',
label: 'Finite drain test',
description: 'Tiny reservoir feeding a drain so the source visibly runs out',
},
];
const quickId = (build: QuickBuildId, suffix: string) =>
`quick-${build}-${suffix}-${crypto.randomUUID().slice(0, 8)}`;
const addOffset = (pos: Vec3, offset: Vec3): Vec3 => [
pos[0] + offset[0],
pos[1] + offset[1],
pos[2] + offset[2],
];
const place = (
build: QuickBuildId,
suffix: string,
part: Omit<PlacedPart, 'id'>,
offset: Vec3,
): PlacedPart => ({
...part,
id: quickId(build, suffix),
position: addOffset(part.position, offset),
rotation: [...part.rotation],
params: { ...part.params },
});
export function nextQuickBuildOffset(parts: PlacedPart[]): Vec3 {
if (!parts.length) return [0, 0, 0];
const maxX = Math.max(...parts.map((p) => p.position[0]));
return [Math.ceil(maxX / 2) * 2 + 4, 0, 0];
}
export function quickBuildParts(build: QuickBuildId, offset: Vec3 = [0, 0, 0]): PlacedPart[] {
const HALF_PI = Math.PI / 2;
if (build === 'dwcSystem') {
return [
place(build, 'dwc-res', {
type: 'reservoir',
position: [0, 0, 0],
rotation: [0, 0, 0],
params: { width: 1.6, depth: 1.6, height: 1.5, level: 75, ph: 6.0, ec: 1.2 },
label: 'DWC Bucket',
}, offset),
place(build, 'dwc-pot-a', {
type: 'netPot',
position: [-0.3, 1.5, 0],
rotation: [0, 0, 0],
params: { size: 1.0 },
}, offset),
place(build, 'dwc-pot-b', {
type: 'netPot',
position: [0.3, 1.5, 0],
rotation: [0, 0, 0],
params: { size: 1.0 },
}, offset),
place(build, 'dwc-comp', {
type: 'airCompressor',
position: [-1.6, 0, 0],
rotation: [0, 0, 0],
params: { gph: 60, frequency: 5, on: 1 },
label: 'DWC Aerator',
}, offset),
place(build, 'dwc-chiller', {
type: 'waterChiller',
position: [0, 0, -1.6],
rotation: [0, 0, 0],
params: { targetTemp: 68, on: 1, diameter: 1.0 },
label: 'Water Chiller',
}, offset),
];
}
if (build === 'finiteDrainTest') {
return [
place(build, 'res', {
type: 'reservoir',
position: [-2, 0, 0],
rotation: [0, 0, 0],
params: { width: 1, depth: 1, height: 1, level: 10, ph: 6.0, ec: 1.2 },
label: 'Finite test reservoir',
}, offset),
place(build, 'pump', {
type: 'pump',
position: [-1.05, 0, 0],
rotation: [0, 0, 0],
params: { gph: 400, maxHeadFt: 8, on: 1 },
label: 'Drain test pump',
}, offset),
place(build, 'pipe', {
type: 'pipe',
position: [0.4, 0.25, 0],
rotation: [0, 0, 0],
params: { length: 2, diameter: 1 },
}, offset),
place(build, 'drain', {
type: 'drain',
position: [1.75, 0, 0],
rotation: [0, 0, 0],
params: { capacityGph: 1000 },
label: 'Open drain',
}, offset),
];
}
if (build === 'dripManifold') {
return [
place(build, 'res', {
type: 'reservoir',
position: [-2, 0, 0],
rotation: [0, 0, 0],
params: { width: 2, depth: 1.4, height: 1, level: 70 },
label: 'Drip reservoir',
}, offset),
place(build, 'pump', {
type: 'pump',
position: [-0.55, 0, 0],
rotation: [0, 0, 0],
params: { gph: 240, maxHeadFt: 8, on: 1 },
label: 'Drip pump',
}, offset),
place(build, 'pipe-feed', {
type: 'pipe',
position: [0.4, 0.25, 0],
rotation: [0, 0, 0],
params: { length: 1, diameter: 0.75 },
}, offset),
place(build, 'tee-a', {
type: 'tee',
position: [1.5, 0.25, 0],
rotation: [0, 0, 0],
params: { diameter: 0.75 },
label: 'Manifold split',
}, offset),
place(build, 'header', {
type: 'pipe',
position: [2.35, 0.25, 0],
rotation: [0, 0, 0],
params: { length: 0.5, diameter: 0.75 },
}, offset),
place(build, 'tee-b', {
type: 'tee',
position: [3.2, 0.25, 0],
rotation: [0, 0, 0],
params: { diameter: 0.75 },
}, offset),
place(build, 'valve-a', {
type: 'valve',
position: [1.5, 0.25, 0.95],
rotation: [0, -HALF_PI, 0],
params: { diameter: 0.5, open: 75 },
}, offset),
place(build, 'branch-a', {
type: 'pipe',
position: [1.5, 0.25, 1.65],
rotation: [0, -HALF_PI, 0],
params: { length: 0.7, diameter: 0.5 },
}, offset),
place(build, 'emit-a', {
type: 'emitter',
position: [1.5, 0.25, 2.2],
rotation: [0, HALF_PI, 0],
params: {},
label: 'Dripper A',
}, offset),
place(build, 'valve-b', {
type: 'valve',
position: [3.2, 0.25, 0.95],
rotation: [0, -HALF_PI, 0],
params: { diameter: 0.5, open: 75 },
}, offset),
place(build, 'branch-b', {
type: 'pipe',
position: [3.2, 0.25, 1.65],
rotation: [0, -HALF_PI, 0],
params: { length: 0.7, diameter: 0.5 },
}, offset),
place(build, 'emit-b', {
type: 'emitter',
position: [3.2, 0.25, 2.2],
rotation: [0, HALF_PI, 0],
params: {},
label: 'Dripper B',
}, offset),
];
}
return [
place(build, 'res', {
type: 'reservoir',
position: [-2.5, 0, 0],
rotation: [0, 0, 0],
params: { width: 2, depth: 1.4, height: 1, level: 70 },
label: 'NFT reservoir',
}, offset),
place(build, 'pump', {
type: 'pump',
position: [-1.05, 0, 0],
rotation: [0, 0, 0],
params: { gph: 300, maxHeadFt: 8, on: 1 },
label: 'NFT pump',
}, offset),
place(build, 'feed', {
type: 'pipe',
position: [0.2, 0.25, 0],
rotation: [0, 0, 0],
params: { length: 1.6, diameter: 1 },
}, offset),
place(build, 'tray', {
type: 'tray',
position: [2.5, 0, 0],
rotation: [0, 0, 0],
params: { length: 3, width: 1.2 },
label: 'NFT channel',
}, offset),
place(build, 'return', {
type: 'pipe',
position: [4.35, 0.15, 0],
rotation: [0, 0, 0],
params: { length: 0.7, diameter: 1 },
}, offset),
place(build, 'drain', {
type: 'drain',
position: [5.05, -0.1, 0],
rotation: [0, 0, 0],
params: { capacityGph: 350 },
label: 'NFT return drain',
}, offset),
place(build, 'pot-a', {
type: 'netPot',
position: [1.8, 0.25, -0.2],
rotation: [0, 0, 0],
params: { size: 0.8 },
}, offset),
place(build, 'pot-b', {
type: 'netPot',
position: [2.5, 0.25, -0.2],
rotation: [0, 0, 0],
params: { size: 0.8 },
}, offset),
place(build, 'pot-c', {
type: 'netPot',
position: [3.2, 0.25, -0.2],
rotation: [0, 0, 0],
params: { size: 0.8 },
}, offset),
];
}
/**
* Starter demo — two independent, fully plumbed runs:
*
* 1. Main loop: reservoir → pump → tee, which splits the flow:
* - riser → overhead return line → emitter spraying back into the
* reservoir,
* - a narrow side branch feeding a correctly-sized floor drain.
* 2. Tower run: a second reservoir + pump lifting water up and over into
* the grow tower's top inlet; the tower's base outlet runs along +Z to
* its own floor drain.
*
* Plus a tray / net-pot / lattice showcase. Connector positions are computed
* so everything is exactly snapped and the demo simulates warning-free.
*/
export function demoParts(): PlacedPart[] {
const HALF_PI = Math.PI / 2;
return [
{
id: 'demo-res',
type: 'reservoir',
position: [-4, 0, 0],
rotation: [0, 0, 0],
params: { width: 2, depth: 1.4, height: 1, level: 70 },
label: 'Main reservoir',
},
{
// Inlet (-0.45,0.25,0) lands exactly on the reservoir's east port (-3,0.25,0).
id: 'demo-pump',
type: 'pump',
position: [-2.55, 0, 0],
rotation: [0, 0, 0],
params: { gph: 550, maxHeadFt: 8, on: 1 },
label: 'Main pump',
},
{
// Splits the flow: a (-2.1,0.25,0) on the pump outlet, b (-0.9,0.25,0)
// continues the main run, c (-1.5,0.25,0.6) feeds the drain branch.
id: 'demo-tee',
type: 'tee',
position: [-1.5, 0.25, 0],
rotation: [0, 0, 0],
params: { diameter: 1 },
},
{
// Main run: spans x -0.9 .. 0.1 at port height.
id: 'demo-pipe1',
type: 'pipe',
position: [-0.4, 0.25, 0],
rotation: [0, 0, 0],
params: { length: 1, diameter: 1 },
},
{
// Turns the run from +X to +Y (upward). a=(0.1,0.25,0), b=(0.6,0.75,0).
id: 'demo-elbow1',
type: 'elbow',
position: [0.1, 0.25, 0],
rotation: [0, 0, 0],
params: { angle: 90, diameter: 1 },
},
{
// Vertical riser: rotated 90° about Z so its axis is Y (y 0.75 .. 2.75).
id: 'demo-pipe2',
type: 'pipe',
position: [0.6, 1.75, 0],
rotation: [0, 0, HALF_PI],
params: { length: 2, diameter: 1 },
},
{
// Turns +Y back to -X (overhead return). a=(0.6,2.75,0), b=(0.1,3.25,0).
id: 'demo-elbow2',
type: 'elbow',
position: [0.6, 2.75, 0],
rotation: [0, 0, HALF_PI],
params: { angle: 90, diameter: 1 },
},
{
// Overhead return: spans x -2.9 .. 0.1 at y 3.25.
id: 'demo-pipe3',
type: 'pipe',
position: [-1.4, 3.25, 0],
rotation: [0, 0, 0],
params: { length: 3, diameter: 1 },
},
{
// Sprays down into the reservoir below (in connector at (-2.9,3.25,0)).
id: 'demo-emitter',
type: 'emitter',
position: [-3.1, 3.25, 0],
rotation: [0, 0, 0],
params: {},
label: 'Return emitter',
},
// --- drain branch (narrow pipe → correctly-sized floor drain) ---
{
// Narrow branch pipe along +Z: rotated -90° about Y so its axis is Z.
// Connectors land on the tee's c port (-1.5,0.25,0.6) and (-1.5,0.25,1.6).
id: 'demo-pipe4',
type: 'pipe',
position: [-1.5, 0.25, 1.1],
rotation: [0, -HALF_PI, 0],
params: { length: 1, diameter: 0.5 },
},
{
// Floor drain; inlet (local (-0.35,0.25,0)) rotates to (-1.5,0.25,1.6).
// Branch carries ~65 GPH — 100 GPH capacity swallows it with margin.
id: 'demo-drain',
type: 'drain',
position: [-1.5, 0, 1.95],
rotation: [0, -HALF_PI, 0],
params: { capacityGph: 100 },
label: 'Floor drain',
},
// --- tower run: reservoir → pump → up & over → tower → floor drain ---
{
// West port at (6.5, 0.25, 1.5) feeds the tower pump's inlet.
id: 'demo-res2',
type: 'reservoir',
position: [7.5, 0, 1.5],
rotation: [0, 0, 0],
params: { width: 2, depth: 1.4, height: 1, level: 60 },
label: 'Tower reservoir',
},
{
// Rotated 180° about Y: inlet lands on (6.5,0.25,1.5), outlet faces -X
// at (5.6,0.25,1.5).
id: 'demo-pump2',
type: 'pump',
position: [6.05, 0, 1.5],
rotation: [0, Math.PI, 0],
params: { gph: 240, maxHeadFt: 8, on: 1 },
label: 'Tower pump',
},
{
// Mirrored elbow: a=(5.6,0.25,1.5) on the pump outlet, b=(5.1,0.75,1.5)
// pointing up.
id: 'demo-elbow-t1',
type: 'elbow',
position: [5.6, 0.25, 1.5],
rotation: [0, Math.PI, 0],
params: { angle: 90, diameter: 1 },
},
{
// Vertical riser: spans y 0.75 .. 4.0 (length 3.25 makes the top land
// exactly where the overhead elbows can step down onto the tower inlet).
id: 'demo-riser2',
type: 'pipe',
position: [5.1, 2.375, 1.5],
rotation: [0, 0, HALF_PI],
params: { length: 3.25, diameter: 1 },
},
{
// Turns +Y to -X: a=(5.1,4.0,1.5), b=(4.6,4.5,1.5).
id: 'demo-elbow-t2',
type: 'elbow',
position: [5.1, 4.0, 1.5],
rotation: [0, 0, HALF_PI],
params: { angle: 90, diameter: 1 },
},
{
// Short overhead hop: spans x 3.5 .. 4.6 at y 4.5.
id: 'demo-pipe-t1',
type: 'pipe',
position: [4.05, 4.5, 1.5],
rotation: [0, 0, 0],
params: { length: 1.1, diameter: 1 },
},
{
// Turns -X to -Y (down): a=(3.5,4.5,1.5), b=(3.0,4.0,1.5) — exactly the
// tower's top inlet.
id: 'demo-elbow-t3',
type: 'elbow',
position: [3.5, 4.5, 1.5],
rotation: [0, 0, Math.PI],
params: { angle: 90, diameter: 1 },
},
{
// Tower outlet (3.45,0.15,1.5) → turn +X to +Z: b=(3.95,0.15,2.0).
id: 'demo-elbow-t4',
type: 'elbow',
position: [3.45, 0.15, 1.5],
rotation: [HALF_PI, 0, 0],
params: { angle: 90, diameter: 1 },
},
{
// Run to the drain along +Z: spans z 2.0 .. 3.5 at y 0.15.
id: 'demo-pipe-t2',
type: 'pipe',
position: [3.95, 0.15, 2.75],
rotation: [0, HALF_PI, 0],
params: { length: 1.5, diameter: 1 },
},
{
// Flush-mounted floor drain (sunk 0.1 ft so its inlet meets the pipe at
// y 0.15). The run delivers ~75 GPH — 100 GPH capacity is plenty.
id: 'demo-drain2',
type: 'drain',
position: [3.95, -0.1, 3.85],
rotation: [0, -HALF_PI, 0],
params: { capacityGph: 100 },
label: 'Tower drain',
},
// --- showcase pieces ---
{
id: 'demo-tower',
type: 'tower',
position: [3, 0, 1.5],
rotation: [0, 0, 0],
params: { height: 4, sites: 6 },
label: 'Tower A',
},
{
id: 'demo-tray',
type: 'tray',
position: [3, 0, -1.5],
rotation: [0, 0, 0],
params: { length: 3, width: 1.2 },
},
{
id: 'demo-pot1',
type: 'netPot',
position: [2.4, 0.25, -1.5],
rotation: [0, 0, 0],
params: { size: 1 },
},
{
id: 'demo-pot2',
type: 'netPot',
position: [3.4, 0.25, -1.5],
rotation: [0, 0, 0],
params: { size: 1 },
},
{
id: 'demo-lattice',
type: 'lattice',
position: [5.5, 0, 0],
rotation: [0, -HALF_PI, 0],
params: { width: 2.5, height: 3 },
},
];
}

94
src/utils/dragState.ts Normal file
View File

@@ -0,0 +1,94 @@
import type { Vec3 } from '../types';
import { useBuilder } from '../store/builderStore';
import { getWorldConnectors } from './connectors';
/**
* Transient (non-reactive) drag state shared between the drag initiators
* (PartMesh body grabs, ConnectorHandles) and the DragPlane in SceneCanvas
* (drag move/end). Kept out of the store to avoid re-rendering on every
* pointer event.
*/
export const dragCtx = {
/** Vector from grab point to the primary part's origin at drag start. */
grabOffset: [0, 0, 0] as Vec3,
/** Whether this drag already recorded an undo snapshot. */
pushed: false,
/** 'body' = grabbed the part itself, 'handle' = grabbed a connector handle. */
mode: 'body' as 'body' | 'handle',
/** All part ids moving rigidly together (the selection at drag start). */
groupIds: [] as string[],
/** Position of every group member when the drag started. */
startPositions: {} as Record<string, Vec3>,
/** World connector key being dragged (handle mode only). */
connectorKey: null as string | null,
/** Pipe endpoint stretch metadata (handle drags on straight pipes only). */
pipeStretch: null as null | {
anchorConnectorId: string;
anchorPos: Vec3;
axis: Vec3;
startLength: number;
},
/** Identifies the active drag-plane orientation; the grab offset is
* recomputed when it changes mid-drag (e.g. Shift toggles vertical). */
planeKey: '',
/** World point the drag plane passes through (grab point, re-anchored
* whenever the plane orientation changes mid-drag). */
planeAnchor: [0, 0, 0] as Vec3,
};
/**
* Start dragging a part (and, rigidly, the rest of the selection if the part
* is selected). `grabPoint` is the world-space point that should stay under
* the cursor; for handle drags it is the connector position.
*/
export function beginPartDrag(opts: {
partId: string;
grabPoint: Vec3;
mode: 'body' | 'handle';
connectorKey?: string;
}) {
const s = useBuilder.getState();
const part = s.parts[opts.partId];
if (!part) return;
const group = s.selectedIds.includes(opts.partId) ? [...s.selectedIds] : [opts.partId];
dragCtx.mode = opts.mode;
dragCtx.connectorKey = opts.connectorKey ?? null;
dragCtx.pipeStretch = null;
dragCtx.grabOffset = [
opts.grabPoint[0] - part.position[0],
opts.grabPoint[1] - part.position[1],
opts.grabPoint[2] - part.position[2],
];
dragCtx.groupIds = group;
dragCtx.startPositions = {};
for (const id of group) {
const p = s.parts[id];
if (p) dragCtx.startPositions[id] = [...p.position] as Vec3;
}
if (opts.mode === 'handle' && part.type === 'pipe' && opts.connectorKey) {
const connectorId = opts.connectorKey.split(':')[1];
const anchorConnectorId = connectorId === 'a' ? 'b' : connectorId === 'b' ? 'a' : null;
if (anchorConnectorId) {
const conns = getWorldConnectors(part);
const dragged = conns.find((c) => c.connectorId === connectorId);
const anchor = conns.find((c) => c.connectorId === anchorConnectorId);
if (dragged && anchor) {
const dx = dragged.pos[0] - anchor.pos[0];
const dy = dragged.pos[1] - anchor.pos[1];
const dz = dragged.pos[2] - anchor.pos[2];
const len = Math.hypot(dx, dy, dz) || (part.params.length ?? 2);
dragCtx.pipeStretch = {
anchorConnectorId,
anchorPos: [...anchor.pos] as Vec3,
axis: [dx / len, dy / len, dz / len],
startLength: part.params.length ?? len,
};
dragCtx.groupIds = [opts.partId];
}
}
}
dragCtx.pushed = false;
dragCtx.planeKey = '';
dragCtx.planeAnchor = [...opts.grabPoint] as Vec3;
s.setDraggingId(opts.partId);
}

325
src/utils/pathfinder.ts Normal file
View File

@@ -0,0 +1,325 @@
import * as THREE from 'three';
import type { PlacedPart, Vec3, WorldConnector } from '../types';
/** Snap a value to the 0.5 ft grid. */
const snapToGrid = (v: number) => Math.round(v * 2) / 2;
/** Round connector direction vectors to integer grid steps. */
function getDirG(dir: Vec3): [number, number, number] {
return [Math.round(dir[0]), Math.round(dir[1]), Math.round(dir[2])];
}
/** Generates a simple UID. */
const uid = () => Math.random().toString(36).substring(2, 10);
/**
* Returns the Axis-Aligned Bounding Box (AABB) in world space for structural obstacles.
* This rotates the local box coordinates and computes the enclosing AABB.
*/
export function getPartAABB(part: PlacedPart): THREE.Box3 | null {
let w = 1, d = 1, h = 1;
let originY = 0;
if (part.type === 'tray') {
w = part.params.length ?? 3;
d = part.params.width ?? 1.2;
h = 0.25;
} else if (part.type === 'wallPanel') {
w = part.params.width ?? 3;
d = 0.5;
h = part.params.height ?? 4;
} else if (part.type === 'tower') {
w = 1.0;
d = 1.0;
h = part.params.height ?? 4;
} else if (part.type === 'pipe') {
const r = (part.params.diameter ?? 1) * 0.08;
w = part.params.length ?? 2;
d = r * 2.5;
h = r * 2.5;
originY = -h / 2;
} else if (part.type === 'elbow' || part.type === 'tee' || part.type === 'coupling' || part.type === 'valve') {
const r = (part.params.diameter ?? 1) * 0.08;
w = 0.6; d = 0.6; h = 0.6;
originY = -h / 2;
} else if (part.type === 'pump') {
w = 0.6; d = 0.6; h = 0.8;
} else {
w = part.params.width ?? 2;
d = part.params.depth ?? 1.4;
h = part.params.height ?? 1;
}
const localMin = new THREE.Vector3(-w / 2, originY, -d / 2);
const localMax = new THREE.Vector3(w / 2, originY + h, d / 2);
const box = new THREE.Box3(localMin, localMax);
const pos = new THREE.Vector3(...part.position);
const rot = new THREE.Euler(...part.rotation, 'XYZ');
const mat = new THREE.Matrix4().compose(
pos,
new THREE.Quaternion().setFromEuler(rot),
new THREE.Vector3(1, 1, 1)
);
box.applyMatrix4(mat);
return box;
}
/**
* Finds a 3D path of points between startPos and endPos on a 0.5 ft grid,
* avoiding the given obstacle AABBs.
*/
export function find3DPath(
startInput: Vec3 | THREE.Vector3,
endInput: Vec3 | THREE.Vector3,
obstacles: THREE.Box3[]
): THREE.Vector3[] | null {
const startPos = startInput instanceof THREE.Vector3 ? startInput.clone() : new THREE.Vector3(...startInput);
const endPos = endInput instanceof THREE.Vector3 ? endInput.clone() : new THREE.Vector3(...endInput);
const startGx = Math.round(startPos.x * 2);
const startGy = Math.round(startPos.y * 2);
const startGz = Math.round(startPos.z * 2);
const endGx = Math.round(endPos.x * 2);
const endGy = Math.round(endPos.y * 2);
const endGz = Math.round(endPos.z * 2);
const startKey = `${startGx},${startGy},${startGz}`;
const endKey = `${endGx},${endGy},${endGz}`;
// Bounding box of the search space
let minGx = Math.min(startGx, endGx) - 12;
let maxGx = Math.max(startGx, endGx) + 12;
let minGy = Math.min(startGy, endGy) - 12;
let maxGy = Math.max(startGy, endGy) + 12;
let minGz = Math.min(startGz, endGz) - 12;
let maxGz = Math.max(startGz, endGz) + 12;
minGy = Math.max(minGy, -4);
// Expand search box to cover all obstacles
for (const box of obstacles) {
minGx = Math.min(minGx, Math.round((box.min.x - 2) * 2));
maxGx = Math.max(maxGx, Math.round((box.max.x + 2) * 2));
minGy = Math.min(minGy, Math.round((box.min.y - 2) * 2));
maxGy = Math.max(maxGy, Math.round((box.max.y + 2) * 2));
minGz = Math.min(minGz, Math.round((box.min.z - 2) * 2));
maxGz = Math.max(maxGz, Math.round((box.max.z + 2) * 2));
}
const openSet = new Set<string>([startKey]);
const cameFrom = new Map<string, string>();
const gScore = new Map<string, number>();
const fScore = new Map<string, number>();
gScore.set(startKey, 0);
const heuristic = (gx1: number, gy1: number, gz1: number, gx2: number, gy2: number, gz2: number) => {
return Math.abs(gx1 - gx2) + Math.abs(gy1 - gy2) + Math.abs(gz1 - gz2);
};
fScore.set(startKey, heuristic(startGx, startGy, startGz, endGx, endGy, endGz));
const maxIterations = 30000;
let iterations = 0;
const margin = 0.08; // 0.08 ft clearance around AABBs
while (openSet.size > 0) {
iterations++;
if (iterations > maxIterations) {
break;
}
let currentKey = '';
let minF = Infinity;
for (const key of openSet) {
const f = fScore.get(key) ?? Infinity;
if (f < minF) {
minF = f;
currentKey = key;
}
}
if (!currentKey) break;
const [cx, cy, cz] = currentKey.split(',').map(Number);
if (currentKey === endKey) {
const gridPath: THREE.Vector3[] = [];
let curr: string | undefined = currentKey;
while (curr) {
const [x, y, z] = curr.split(',').map(Number);
gridPath.push(new THREE.Vector3(x / 2, y / 2, z / 2));
curr = cameFrom.get(curr);
}
gridPath.reverse();
const rawPath: THREE.Vector3[] = [startPos.clone()];
for (const pt of gridPath) {
if (rawPath[rawPath.length - 1].distanceTo(pt) > 0.05) {
rawPath.push(pt);
}
}
if (rawPath[rawPath.length - 1].distanceTo(endPos) > 0.05) {
rawPath.push(endPos.clone());
}
return rawPath;
}
openSet.delete(currentKey);
const neighbors = [
[cx + 1, cy, cz],
[cx - 1, cy, cz],
[cx, cy + 1, cz],
[cx, cy - 1, cz],
[cx, cy, cz + 1],
[cx, cy, cz - 1],
];
for (const [nx, ny, nz] of neighbors) {
if (nx < minGx || nx > maxGx || ny < minGy || ny > maxGy || nz < minGz || nz > maxGz) {
continue;
}
const neighborKey = `${nx},${ny},${nz}`;
const neighborPt = new THREE.Vector3(nx / 2, ny / 2, nz / 2);
// Check collisions with obstacles, ignoring start and end to allow entry/exit
if (neighborKey !== endKey && neighborKey !== startKey) {
let collides = false;
for (const box of obstacles) {
const expandedBox = box.clone().expandByScalar(margin);
if (expandedBox.containsPoint(neighborPt)) {
collides = true;
break;
}
}
if (collides) continue;
}
const currentCost = gScore.get(currentKey) ?? Infinity;
const dx = nx - cx;
const dy = ny - cy;
const dz = nz - cz;
let isTurn = false;
const parentKey = cameFrom.get(currentKey);
if (parentKey) {
const [px, py, pz] = parentKey.split(',').map(Number);
const prevDx = cx - px;
const prevDy = cy - py;
const prevDz = cz - pz;
if (prevDx !== dx || prevDy !== dy || prevDz !== dz) {
isTurn = true;
}
}
const stepCost = 0.5 + (isTurn ? 5.0 : 0);
const tentativeGScore = currentCost + stepCost;
if (tentativeGScore < (gScore.get(neighborKey) ?? Infinity)) {
cameFrom.set(neighborKey, currentKey);
gScore.set(neighborKey, tentativeGScore);
fScore.set(neighborKey, tentativeGScore + heuristic(nx, ny, nz, endGx, endGy, endGz));
openSet.add(neighborKey);
}
}
}
return null;
}
/**
* Converts a 3D path of points into straight pipes and 90-degree elbows,
* simplifying collinear segments along the way.
*/
export function convertPathToParts(
path: THREE.Vector3[],
diameter: number,
roomId?: string
): PlacedPart[] {
if (path.length < 2) return [];
// 1. Simplify collinear segments
const simplified: THREE.Vector3[] = [path[0]];
for (let i = 1; i < path.length - 1; i++) {
const prev = simplified[simplified.length - 1];
const curr = path[i];
const next = path[i + 1];
const dir1 = curr.clone().sub(prev).normalize();
const dir2 = next.clone().sub(curr).normalize();
if (dir1.dot(dir2) < 0.999) {
simplified.push(curr);
}
}
simplified.push(path[path.length - 1]);
const result: PlacedPart[] = [];
const B = 0.5; // Elbow bend radius (0.5 ft)
const isCorner = new Array(simplified.length).fill(false);
for (let i = 1; i < simplified.length - 1; i++) {
isCorner[i] = true;
}
// 2. Generate elbows and straight pipes
for (let i = 0; i < simplified.length - 1; i++) {
const pA = simplified[i];
const pB = simplified[i + 1];
const dir = pB.clone().sub(pA).normalize();
const pipeStart = pA.clone().addScaledVector(dir, isCorner[i] ? B : 0);
const pipeEnd = pB.clone().addScaledVector(dir, isCorner[i + 1] ? -B : 0);
const length = pipeStart.distanceTo(pipeEnd);
if (length > 0.05) {
const pos = pipeStart.clone().add(pipeEnd).multiplyScalar(0.5);
const quat = new THREE.Quaternion().setFromUnitVectors(
new THREE.Vector3(1, 0, 0),
dir
);
const euler = new THREE.Euler().setFromQuaternion(quat, 'XYZ');
result.push({
id: `pipe-${uid()}`,
type: 'pipe',
position: [pos.x, pos.y, pos.z],
rotation: [euler.x, euler.y, euler.z],
params: { length, diameter },
roomId,
});
}
if (isCorner[i + 1]) {
const cornerPt = pB;
const nextPt = simplified[i + 2];
const dir1 = dir;
const dir2 = nextPt.clone().sub(cornerPt).normalize();
const elbowPos = cornerPt.clone().addScaledVector(dir1, -B);
const m = new THREE.Matrix4().makeBasis(
dir1,
dir2,
new THREE.Vector3().crossVectors(dir1, dir2).normalize()
);
const euler = new THREE.Euler().setFromRotationMatrix(m, 'XYZ');
result.push({
id: `elbow-${uid()}`,
type: 'elbow',
position: [elbowPos.x, elbowPos.y, elbowPos.z],
rotation: [euler.x, euler.y, euler.z],
params: { angle: 90, diameter },
roomId,
});
}
}
return result;
}

144
src/utils/serializer.ts Normal file
View File

@@ -0,0 +1,144 @@
import type { PlacedPart, ProjectFile, PartType } from '../types';
import { PART_DEFS } from '../parts/catalog';
/**
* ProjectSerializer — JSON persistence.
* - Named projects in localStorage.
* - Autosave (restored on next visit).
* - Export/import .json files.
*/
const PROJECTS_KEY = 'hydro-builder:projects';
const AUTOSAVE_KEY = 'hydro-builder:autosave';
export function toProjectFile(name: string, parts: PlacedPart[]): ProjectFile {
return { app: 'hydro-builder', version: 1, name, savedAt: new Date().toISOString(), parts };
}
/** Validate an arbitrary JSON value as a ProjectFile, dropping bad parts or throwing on corruption. */
export function parseProjectFile(raw: unknown): ProjectFile {
const obj = raw as Partial<ProjectFile>;
if (!obj || obj.app !== 'hydro-builder' || !Array.isArray(obj.parts)) {
throw new Error('Not a valid Hydro Builder project file.');
}
const VALID_CATEGORIES = new Set<string>([
'Plumbing',
'Flow Control',
'Growing',
'Structure',
'Environment',
]);
for (const p of obj.parts) {
if (!p || typeof p !== 'object') {
throw new Error('Project contains invalid or null part data.');
}
if (typeof p.id !== 'string' || !p.id) {
throw new Error('Part is missing a valid ID.');
}
if (typeof p.type !== 'string' || !(p.type in PART_DEFS)) {
throw new Error(`Part ${p.id} has an invalid or unrecognized type: ${p.type}.`);
}
const category = PART_DEFS[p.type as PartType].category;
if (!VALID_CATEGORIES.has(category)) {
throw new Error(`Part ${p.id} has an invalid category: ${category}.`);
}
if (
!Array.isArray(p.position) ||
p.position.length !== 3 ||
!p.position.every((num) => typeof num === 'number' && Number.isFinite(num))
) {
throw new Error(`Part ${p.id} must have a position with an array of 3 numbers.`);
}
if (
!Array.isArray(p.rotation) ||
p.rotation.length !== 3 ||
!p.rotation.every((num) => typeof num === 'number' && Number.isFinite(num))
) {
throw new Error(`Part ${p.id} must have a rotation with an array of 3 numbers.`);
}
}
const parts = obj.parts as PlacedPart[];
return toProjectFileNamed(obj.name ?? 'Imported project', parts, obj.savedAt);
}
function toProjectFileNamed(name: string, parts: PlacedPart[], savedAt?: string): ProjectFile {
return { app: 'hydro-builder', version: 1, name, savedAt: savedAt ?? new Date().toISOString(), parts };
}
// ---------- localStorage projects ----------
function readProjects(): Record<string, ProjectFile> {
try {
return JSON.parse(localStorage.getItem(PROJECTS_KEY) ?? '{}');
} catch {
return {};
}
}
export function listProjects(): { name: string; savedAt: string }[] {
return Object.values(readProjects())
.map((p) => ({ name: p.name, savedAt: p.savedAt }))
.sort((a, b) => b.savedAt.localeCompare(a.savedAt));
}
export function saveProject(name: string, parts: PlacedPart[]) {
const all = readProjects();
all[name] = toProjectFile(name, parts);
localStorage.setItem(PROJECTS_KEY, JSON.stringify(all));
}
export function loadProject(name: string): ProjectFile | null {
return readProjects()[name] ?? null;
}
export function deleteProject(name: string) {
const all = readProjects();
delete all[name];
localStorage.setItem(PROJECTS_KEY, JSON.stringify(all));
}
// ---------- autosave ----------
export function autosave(name: string, parts: PlacedPart[], email?: string) {
try {
const key = email ? `hydro-builder:autosave:${email}` : AUTOSAVE_KEY;
localStorage.setItem(key, JSON.stringify(toProjectFile(name, parts)));
} catch {
/* storage full — ignore */
}
}
export function loadAutosave(email?: string): ProjectFile | null {
try {
const key = email ? `hydro-builder:autosave:${email}` : AUTOSAVE_KEY;
const raw = localStorage.getItem(key);
return raw ? parseProjectFile(JSON.parse(raw)) : null;
} catch {
return null;
}
}
// ---------- file export / import ----------
export function downloadProject(name: string, parts: PlacedPart[]) {
const blob = new Blob([JSON.stringify(toProjectFile(name, parts), null, 2)], {
type: 'application/json',
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${name.replace(/[^a-z0-9-_ ]/gi, '').trim() || 'hydro-system'}.json`;
a.click();
URL.revokeObjectURL(url);
}
export async function importProjectFile(file: File): Promise<ProjectFile> {
const text = await file.text();
return parseProjectFile(JSON.parse(text));
}

View File

@@ -0,0 +1,194 @@
import type { Finding, FindingSeverity } from './types';
/**
* DesignCheckPanel — self-contained results drawer for the Design Check.
*
* Purely presentational: findings are passed in, part navigation goes out via
* `onSelectPart`. It deliberately does NOT touch the zustand store so it can
* never break when the store shape changes.
*
* Usage (UI agent):
* const findings = validateSystem(parts, sim); // on Play / "Check Build"
* <DesignCheckPanel findings={findings} onSelectPart={setSelected} onClose={...} />
*/
export interface DesignCheckPanelProps {
findings: Finding[];
/** Called with a part id when the user clicks "Show part". */
onSelectPart?: (id: string) => void;
onApplyFix?: (finding: Finding) => void;
onClose: () => void;
}
const SEVERITIES: FindingSeverity[] = ['error', 'warning', 'info'];
const SEVERITY_META: Record<
FindingSeverity,
{ heading: string; noun: [string, string]; card: string; dot: string; text: string }
> = {
error: {
heading: 'Errors',
noun: ['error', 'errors'],
card: 'border-rose-900/60 bg-rose-950/40',
dot: 'bg-rose-500',
text: 'text-rose-300',
},
warning: {
heading: 'Warnings',
noun: ['warning', 'warnings'],
card: 'border-amber-900/60 bg-amber-950/30',
dot: 'bg-amber-400',
text: 'text-amber-300',
},
info: {
heading: 'Tips',
noun: ['tip', 'tips'],
card: 'border-sky-900/60 bg-sky-950/30',
dot: 'bg-sky-400',
text: 'text-sky-300',
},
};
function summaryLine(findings: Finding[]): string {
const chunks: string[] = [];
for (const sev of SEVERITIES) {
const n = findings.filter((f) => f.severity === sev).length;
if (n > 0) chunks.push(`${n} ${SEVERITY_META[sev].noun[n === 1 ? 0 : 1]}`);
}
return chunks.join(' \u00b7 ');
}
function FindingCard({
finding,
onSelectPart,
onApplyFix,
}: {
finding: Finding;
onSelectPart?: (id: string) => void;
onApplyFix?: (finding: Finding) => void;
}) {
const meta = SEVERITY_META[finding.severity];
const canApplyFix =
finding.id.startsWith('dead-leg:') ||
finding.id.startsWith('diameter-mismatch:') ||
finding.id.startsWith('pump-dead:');
return (
<div className={`rounded-lg border px-3 py-2.5 ${meta.card}`}>
<div className="flex items-start gap-2">
<span className={`mt-1.5 h-2 w-2 shrink-0 rounded-full ${meta.dot}`} />
<div className="min-w-0 flex-1">
<h4 className={`text-xs font-semibold leading-snug ${meta.text}`}>{finding.title}</h4>
<p className="mt-0.5 text-[11px] leading-snug text-zinc-400">{finding.detail}</p>
{finding.fix && (
<p className="mt-1.5 rounded-md bg-zinc-900/80 px-2 py-1 text-[11px] leading-snug text-zinc-300">
<span className="font-semibold text-sky-400">Fix: </span>
{finding.fix}
</p>
)}
{finding.partIds.length > 0 && onSelectPart && (
<div className="mt-1.5 flex flex-wrap gap-1.5">
{finding.partIds.map((id, i) => (
<button
key={id}
onClick={() => onSelectPart(id)}
className="rounded-md border border-zinc-700 bg-zinc-900 px-2 py-0.5 text-[10px] font-semibold text-sky-400 hover:border-sky-700 hover:bg-zinc-800"
>
Show part{finding.partIds.length > 1 ? ` ${i + 1}` : ''}
</button>
))}
{canApplyFix && onApplyFix && (
<button
onClick={() => onApplyFix(finding)}
className="rounded-md border border-emerald-700 bg-emerald-950 px-2 py-0.5 text-[10px] font-semibold text-emerald-300 hover:border-emerald-500 hover:bg-emerald-900"
>
Apply fix
</button>
)}
</div>
)}
</div>
</div>
</div>
);
}
export function DesignCheckPanel({ findings, onSelectPart, onApplyFix, onClose }: DesignCheckPanelProps) {
const clean = findings.length === 0;
return (
<div className="fixed inset-0 z-50 flex justify-end" role="dialog" aria-modal="true">
{/* Backdrop */}
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
{/* Drawer */}
<aside className="relative flex h-full w-[26rem] max-w-full flex-col border-l border-zinc-800 bg-zinc-950 shadow-2xl">
<header className="flex items-start justify-between gap-3 border-b border-zinc-800 px-4 py-3">
<div>
<h2 className="text-sm font-bold tracking-wide text-zinc-100">Design Check</h2>
<p className="mt-0.5 text-[11px] text-zinc-500">
{clean ? 'All rules passed' : summaryLine(findings)}
</p>
</div>
<button
onClick={onClose}
aria-label="Close design check"
className="rounded-md border border-zinc-800 bg-zinc-900 px-2 py-1 text-xs font-semibold text-zinc-400 hover:border-zinc-600 hover:text-zinc-200"
>
Close
</button>
</header>
<div className="flex-1 overflow-y-auto px-4 py-3">
{clean ? (
<div className="flex h-full flex-col items-center justify-center gap-3 text-center">
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-emerald-950 ring-1 ring-emerald-800">
<svg viewBox="0 0 20 20" className="h-6 w-6 text-emerald-400" fill="none">
<path
d="M4 10.5l4 4 8-9"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</span>
<p className="text-sm font-semibold text-emerald-400">
Build validated no issues
</p>
<p className="max-w-[16rem] text-[11px] text-zinc-500">
Every design rule passed. Press Play to watch the water run.
</p>
</div>
) : (
<div className="flex flex-col gap-4">
{SEVERITIES.map((sev) => {
const group = findings.filter((f) => f.severity === sev);
if (!group.length) return null;
const meta = SEVERITY_META[sev];
return (
<section key={sev}>
<h3 className="mb-1.5 flex items-center gap-1.5 text-[10px] font-bold tracking-wider text-zinc-500 uppercase">
<span className={`h-1.5 w-1.5 rounded-full ${meta.dot}`} />
{meta.heading}
<span className="font-mono text-zinc-600">{group.length}</span>
</h3>
<div className="flex flex-col gap-1.5">
{group.map((f) => (
<FindingCard
key={f.id}
finding={f}
onSelectPart={onSelectPart}
onApplyFix={onApplyFix}
/>
))}
</div>
</section>
);
})}
</div>
)}
</div>
</aside>
</div>
);
}

View File

@@ -0,0 +1,59 @@
import type { PlacedPart, SimResult } from '../types';
import { computeRoomMetrics } from '../environment/roomMetrics';
import { simulate } from '../simulation/flowSimulator';
import { buildSystemGraph } from './graph';
import { ALL_RULES } from './rules';
import type { Finding, FindingSeverity, RuleContext } from './types';
const SEVERITY_ORDER: Record<FindingSeverity, number> = { error: 0, warning: 1, info: 2 };
/**
* Run the full Design Check over a build.
*
* @param parts Placed parts — either the store's `Record<id, PlacedPart>` or
* a plain array.
* @param sim Optional pre-computed simulation result (e.g. the one already
* driving the viewport). When omitted, `simulate()` is run here.
* @returns Findings sorted errors → warnings → info. Empty array = clean build.
*/
export function validateSystem(
parts: Record<string, PlacedPart> | PlacedPart[],
sim?: SimResult,
): Finding[] {
const partsMap: Record<string, PlacedPart> = Array.isArray(parts)
? Object.fromEntries(parts.map((p) => [p.id, p]))
: parts;
const partList = Object.values(partsMap);
let simResult = sim;
if (!simResult) {
try {
simResult = simulate(partsMap);
} catch {
simResult = undefined; // rules degrade gracefully without sim data
}
}
const ctx: RuleContext = {
partsMap,
parts: partList,
sim: simResult,
graph: buildSystemGraph(partList),
roomMetrics: computeRoomMetrics(partsMap),
};
const findings: Finding[] = [];
for (const rule of ALL_RULES) {
try {
findings.push(...rule.run(ctx));
} catch (err) {
// A broken rule (e.g. after a sibling changes a shape) must never take
// down the whole check.
console.warn(`[design-check] rule "${rule.id}" failed:`, err);
}
}
return findings.sort(
(a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity],
);
}

256
src/validation/graph.ts Normal file
View File

@@ -0,0 +1,256 @@
import type { PlacedPart, PartType, Vec3, WorldConnector } from '../types';
import { PART_DEFS } from '../parts/catalog';
import { CONNECT_DIST, dist3, getWorldConnectors } from '../utils/connectors';
/**
* Connectivity graph for the Design Check — built independently of the flow
* simulator so validation rules keep working even if the simulator changes.
*
* Two levels of structure:
* - Part level: `neighbors` / `components` (which parts are plugged together).
* - Node level: `nodes` — joined-connector groups (`N:*`) and per-part
* internal nodes (`P:<partId>`) with world positions, used for elevation
* walks (siphon detection) and path tracing.
*/
/** A pair of connectors (on different parts) sitting within CONNECT_DIST. */
export interface Joint {
a: WorldConnector;
b: WorldConnector;
}
/** Axis-aligned bounding box (ft, world space). */
export interface AABB {
min: Vec3;
max: Vec3;
}
export interface GraphNode {
id: string;
/** Representative world position (connector position or part position). */
pos: Vec3;
edges: { to: string; partId: string }[];
}
export interface SystemGraph {
/** World-space connectors for every part (empty array if none/unknown type). */
connectorsByPart: Map<string, WorldConnector[]>;
/** All joined connector pairs, deduped. */
joints: Joint[];
/** Connector keys (`partId:connectorId`) that are joined to something. */
joinedKeys: Set<string>;
/** partId -> partIds it is physically connected to. */
neighbors: Map<string, Set<string>>;
/** partId -> index into `components`. */
componentOf: Map<string, number>;
/** Connected components (sets of part ids), parts with connectors only. */
components: Set<string>[];
/** Node-level graph for elevation/path walks. */
nodes: Map<string, GraphNode>;
/** connector key -> node id of its merged group. */
nodeOfConnector: Map<string, string>;
}
/** Parts where water can legitimately exit the network. */
export const EXIT_TYPES = new Set<string>([
'reservoir',
'emitter',
'drain',
'tower',
'tray',
'wallPanel',
]);
/**
* getWorldConnectors throws on part types missing from PART_DEFS (siblings
* may add types); fall back to "no connectors" so unknown parts degrade to
* passive obstacles instead of crashing validation.
*/
export function safeWorldConnectors(part: PlacedPart): WorldConnector[] {
if (!PART_DEFS[part.type as PartType]) return [];
try {
return getWorldConnectors(part);
} catch {
return [];
}
}
/** Display name for a part: user label, catalog label, or raw type. */
export function partName(part: PlacedPart | undefined): string {
if (!part) return 'Unknown part';
return part.label ?? PART_DEFS[part.type as PartType]?.label ?? part.type;
}
/** Is the pump explicitly switched off? (`on` per catalog; `enabled` legacy). */
export function isPumpOff(part: PlacedPart | undefined): boolean {
return part?.params?.on === 0 || part?.params?.enabled === 0;
}
/** Water can exit the network through this part. */
export function isExitPart(part: PlacedPart | undefined): boolean {
if (!part) return false;
if (EXIT_TYPES.has(part.type)) return true;
// Generic: future "Growing" parts that carry water count as exits too.
const def = PART_DEFS[part.type as PartType];
return def?.category === 'Growing' && safeWorldConnectors(part).length > 0;
}
/**
* Approximate world AABB, ignoring rotation. Combines the part position +
* connector positions (padded) with param-driven extents (width/depth/
* length/height) so vessels and structures get a sensible volume.
*/
export function approxAABB(part: PlacedPart, conns: WorldConnector[]): AABB {
const [x, y, z] = part.position;
const p = part.params ?? {};
const pad = 0.2;
const pts: Vec3[] = [part.position, ...conns.map((c) => c.pos)];
const min: Vec3 = [Infinity, Infinity, Infinity];
const max: Vec3 = [-Infinity, -Infinity, -Infinity];
for (const pt of pts) {
for (let i = 0; i < 3; i++) {
min[i] = Math.min(min[i], pt[i] - pad);
max[i] = Math.max(max[i], pt[i] + pad);
}
}
const halfW = (p.width ?? 0) / 2;
if (halfW > 0) {
min[0] = Math.min(min[0], x - halfW);
max[0] = Math.max(max[0], x + halfW);
}
const halfL = (p.length ?? 0) / 2;
if (halfL > 0) {
min[0] = Math.min(min[0], x - halfL);
max[0] = Math.max(max[0], x + halfL);
}
const halfD = (p.depth ?? p.width ?? 0) / 2;
if (halfD > 0) {
min[2] = Math.min(min[2], z - halfD);
max[2] = Math.max(max[2], z + halfD);
}
// Height-bearing parts (towers, panels, lattices, tanks) sit on their base.
if ((p.height ?? 0) > 0) {
min[1] = Math.min(min[1], y);
max[1] = Math.max(max[1], y + p.height);
}
return { min, max };
}
// ---------- union-find ----------
class UnionFind {
private parent = new Map<string, string>();
find(x: string): string {
let p = this.parent.get(x) ?? x;
if (p !== x) {
p = this.find(p);
this.parent.set(x, p);
}
return p;
}
union(a: string, b: string) {
const ra = this.find(a);
const rb = this.find(b);
if (ra !== rb) this.parent.set(ra, rb);
}
}
// ---------- graph construction ----------
export function buildSystemGraph(parts: PlacedPart[]): SystemGraph {
const connectorsByPart = new Map<string, WorldConnector[]>();
const allConnectors: WorldConnector[] = [];
for (const part of parts) {
const conns = safeWorldConnectors(part);
connectorsByPart.set(part.id, conns);
allConnectors.push(...conns);
}
// 1. Joints: connector pairs on different parts within CONNECT_DIST.
const joints: Joint[] = [];
const joinedKeys = new Set<string>();
const uf = new UnionFind();
for (let i = 0; i < allConnectors.length; i++) {
for (let j = i + 1; j < allConnectors.length; j++) {
const a = allConnectors[i];
const b = allConnectors[j];
if (a.partId === b.partId) continue;
if (dist3(a.pos, b.pos) < CONNECT_DIST) {
joints.push({ a, b });
joinedKeys.add(a.key);
joinedKeys.add(b.key);
uf.union(a.key, b.key);
}
}
}
// 2. Part-level adjacency + components.
const neighbors = new Map<string, Set<string>>();
const addNeighbor = (a: string, b: string) => {
let s = neighbors.get(a);
if (!s) neighbors.set(a, (s = new Set()));
s.add(b);
};
for (const { a, b } of joints) {
addNeighbor(a.partId, b.partId);
addNeighbor(b.partId, a.partId);
}
const components: Set<string>[] = [];
const componentOf = new Map<string, number>();
for (const part of parts) {
if (!connectorsByPart.get(part.id)?.length) continue;
if (componentOf.has(part.id)) continue;
const comp = new Set<string>();
const stack = [part.id];
componentOf.set(part.id, components.length);
while (stack.length) {
const id = stack.pop()!;
comp.add(id);
for (const nb of neighbors.get(id) ?? []) {
if (!componentOf.has(nb)) {
componentOf.set(nb, components.length);
stack.push(nb);
}
}
}
components.push(comp);
}
// 3. Node-level graph (connector groups + part internal nodes).
const nodes = new Map<string, GraphNode>();
const nodeOfConnector = new Map<string, string>();
const getNode = (id: string, pos: Vec3): GraphNode => {
let n = nodes.get(id);
if (!n) {
n = { id, pos, edges: [] };
nodes.set(id, n);
}
return n;
};
for (const part of parts) {
const conns = connectorsByPart.get(part.id) ?? [];
if (!conns.length) continue;
const pNode = getNode(`P:${part.id}`, part.position);
for (const c of conns) {
const groupId = `N:${uf.find(c.key)}`;
nodeOfConnector.set(c.key, groupId);
const gNode = getNode(groupId, c.pos);
gNode.edges.push({ to: pNode.id, partId: part.id });
pNode.edges.push({ to: groupId, partId: part.id });
}
}
return {
connectorsByPart,
joints,
joinedKeys,
neighbors,
componentOf,
components,
nodes,
nodeOfConnector,
};
}

26
src/validation/index.ts Normal file
View File

@@ -0,0 +1,26 @@
/**
* Design Check — build validation for Hydro Builder.
*
* Public API:
*
* import { validateSystem, DesignCheckPanel } from './validation';
*
* // On Play (or a "Check Build" button). `sim` is optional — pass the
* // result you already have from useSimulation() to avoid re-simulating.
* const findings = validateSystem(parts, sim);
*
* // Render the results drawer (purely presentational):
* <DesignCheckPanel
* findings={findings}
* onSelectPart={(id) => useBuilder.getState().setSelected(id)}
* onClose={() => setShowCheck(false)}
* />
*/
export { validateSystem } from './designCheck';
export { DesignCheckPanel } from './DesignCheckPanel';
export type { DesignCheckPanelProps } from './DesignCheckPanel';
export type { Finding, FindingSeverity, RuleContext, ValidationRule } from './types';
export { ALL_RULES } from './rules';
export { buildSystemGraph } from './graph';
export type { SystemGraph } from './graph';

885
src/validation/rules.ts Normal file
View File

@@ -0,0 +1,885 @@
import type { PlacedPart } from '../types';
import type { Finding, RuleContext, ValidationRule } from './types';
import { approxAABB, isExitPart, isPumpOff, partName } from './graph';
import { computeReservoirWaterStats } from '../simulation/flowSimulator';
import { useBuilder } from '../store/builderStore';
import { dist3 } from '../utils/connectors';
/**
* Design-check rules. Each rule is a pure function over a RuleContext and is
* registered in `ALL_RULES` at the bottom — add new rules there.
*
* Defensive style: sibling agents may evolve the simulator/part shapes, so
* everything reads via optional chaining and tolerates missing data.
*/
const fmt = (n: number, digits = 1) => Number(n.toFixed(digits)).toString();
/** Fraction of max head above which a pump is "near its limit". */
export const HEAD_MARGIN_RATIO = 0.8;
/** A flow path that descends this many feet after a climb risks siphoning. */
export const SIPHON_DROP_FT = 2;
/** Horizontal pipes above this height (ft) need support. */
export const SPAN_MIN_HEIGHT_FT = 1.5;
/** Horizontal pipes longer than this (ft) need support. */
export const SPAN_MIN_LENGTH_FT = 4;
/** A support must reach within this distance (ft) beneath a pipe end. */
export const SUPPORT_REACH_FT = 0.6;
// ---------- a. pump head margin ----------
function getPumpStaticHead(ctx: RuleContext, pumpId: string): number {
const pump = ctx.partsMap[pumpId];
if (!pump) return 0;
const conns = ctx.graph.connectorsByPart.get(pumpId) ?? [];
const outlet = conns.find((c) => c.connectorId === 'out') ?? conns[conns.length - 1];
if (!outlet) return 0;
let maxY = pump.position[1];
const queue = [outlet.partId];
const visited = new Set<string>([pumpId]);
while (queue.length > 0) {
const currId = queue.shift()!;
if (visited.has(currId)) continue;
visited.add(currId);
const part = ctx.partsMap[currId];
if (part) {
maxY = Math.max(maxY, part.position[1]);
}
const nbs = ctx.graph.neighbors.get(currId);
if (nbs) {
for (const nb of nbs) {
if (!visited.has(nb)) {
queue.push(nb);
}
}
}
}
return Math.max(0, maxY - pump.position[1]);
}
function pumpHeadMargin(ctx: RuleContext): Finding[] {
const findings: Finding[] = [];
for (const pr of ctx.sim?.pumps ?? []) {
const pump = ctx.partsMap[pr?.pumpId ?? ''];
if (!pump || isPumpOff(pump)) continue;
const maxHead = pr.maxHeadFt ?? 0;
const head = getPumpStaticHead(ctx, pump.id);
if (maxHead <= 0) continue;
const ratio = head / maxHead;
if (ratio <= HEAD_MARGIN_RATIO) continue;
const name = partName(pump);
// Lowering the high point by this much restores a 20% margin.
const lowerBy = Math.max(0.1, head - HEAD_MARGIN_RATIO * maxHead);
const fix = `Choose a higher-head pump or lower the highest point by ${fmt(lowerBy)} ft`;
if (ratio >= 1) {
findings.push({
id: `pump-head:${pump.id}`,
severity: 'error',
title: 'Pump cannot reach the highest point',
detail: `${name} must lift water ${fmt(head)} ft but its max head is ${fmt(maxHead)} ft — it delivers no flow.`,
partIds: [pump.id],
fix,
});
} else {
findings.push({
id: `pump-head:${pump.id}`,
severity: 'warning',
title: `Pump near its limit (${Math.round(ratio * 100)}%), keep \u226520% margin`,
detail: `${name} is lifting ${fmt(head)} ft of ${fmt(maxHead)} ft max head, so flow is heavily reduced and the pump will wear quickly.`,
partIds: [pump.id],
fix,
});
}
}
return findings;
}
// ---------- b. pump starved / off ----------
/** BFS the part graph from the pump inlet (never through the pump) for a reservoir. */
function inletReachesReservoir(ctx: RuleContext, pumpId: string, inletKey: string): boolean {
const start: string[] = [];
for (const { a, b } of ctx.graph.joints) {
if (a.key === inletKey) start.push(b.partId);
if (b.key === inletKey) start.push(a.partId);
}
const seen = new Set<string>([pumpId, ...start]);
const queue = [...start];
while (queue.length) {
const id = queue.shift()!;
if (ctx.partsMap[id]?.type === 'reservoir') return true;
for (const nb of ctx.graph.neighbors.get(id) ?? []) {
if (!seen.has(nb)) {
seen.add(nb);
queue.push(nb);
}
}
}
return false;
}
function pumpStarvedOrOff(ctx: RuleContext): Finding[] {
const findings: Finding[] = [];
for (const pump of ctx.parts) {
if (pump.type !== 'pump') continue;
const name = partName(pump);
if (isPumpOff(pump)) {
findings.push({
id: `pump-dead:${pump.id}`,
severity: 'error',
title: 'Pump is switched off',
detail: `${name} is powered off, so it delivers no flow.`,
partIds: [pump.id],
fix: 'Turn the pump on (power toggle in the inspector).',
});
continue;
}
const conns = ctx.graph.connectorsByPart.get(pump.id) ?? [];
const inlet = conns.find((c) => c.connectorId === 'in') ?? conns[0];
const outlet = conns.find((c) => c.connectorId === 'out') ?? conns[conns.length - 1];
const hasOutlet = outlet && ctx.graph.joinedKeys.has(outlet.key);
const hasInlet = inlet && ctx.graph.joinedKeys.has(inlet.key);
const pr = ctx.sim?.pumps?.find((p) => p?.pumpId === pump.id);
if (pr && (pr.gph ?? 0) > 0 && hasOutlet && hasInlet) continue;
// Over-head pumps are owned by the head-margin rule — don't double report.
const staticHead = getPumpStaticHead(ctx, pump.id);
if (pr && (pr.maxHeadFt ?? 0) > 0 && staticHead >= (pr.maxHeadFt ?? 0)) continue;
let detail: string;
let fix: string;
if (inlet && !ctx.graph.joinedKeys.has(inlet.key)) {
detail = `${name}'s inlet is not connected to anything — there is no water source.`;
fix = 'Connect the pump inlet to a reservoir.';
} else if (inlet && !inletReachesReservoir(ctx, pump.id, inlet.key)) {
detail = `${name}'s inlet line never reaches a reservoir — there is no water source.`;
fix = 'Route the inlet line back to a reservoir.';
} else if (outlet && !ctx.graph.joinedKeys.has(outlet.key)) {
detail = `${name}'s outlet is not connected to anything — water has nowhere to go.`;
fix = 'Connect the pump outlet to your plumbing.';
} else {
const simMsg = ctx.sim?.warnings?.find(
(w) => w?.partId === pump.id && w?.level !== 'info',
)?.message;
detail = simMsg ?? `${name} delivers no flow.`;
fix = 'Check for closed valves and verify the line reaches an outlet.';
}
findings.push({
id: `pump-dead:${pump.id}`,
severity: 'error',
title: 'Pump delivers no flow',
detail,
partIds: [pump.id],
fix,
});
}
return findings;
}
// ---------- c. mismatched diameters ----------
function getConnectorDiameter(part: PlacedPart, connId: string): number {
if (part.type === 'reducer' || part.type === 'reducingElbow') {
return connId === 'a' ? (part.params.diameterA ?? 1.5) : (part.params.diameterB ?? 1.0);
}
if (part.type === 'reducingTee') {
return connId === 'c' || connId === 'branch' ? (part.params.diameterB ?? 1.0) : (part.params.diameterA ?? 1.5);
}
return part.params.diameter ?? 1.0;
}
function mismatchedDiameters(ctx: RuleContext): Finding[] {
const findings: Finding[] = [];
const seen = new Set<string>();
for (const { a, b } of ctx.graph.joints) {
const pa = ctx.partsMap[a.partId];
const pb = ctx.partsMap[b.partId];
if (!pa || !pb) continue;
const da = getConnectorDiameter(pa, a.connectorId);
const db = getConnectorDiameter(pb, b.connectorId);
if (Math.abs(da - db) < 0.01) continue;
const pairKey = [a.partId, b.partId].sort().join(':');
if (seen.has(pairKey)) continue;
seen.add(pairKey);
const nameA = partName(pa);
const nameB = partName(pb);
findings.push({
id: `diameter-mismatch:${pairKey}`,
severity: 'warning',
title: 'Mismatched pipe diameters at a joint',
detail: `${nameA} (${fmt(da, 2)} in) is joined to ${nameB} (${fmt(db, 2)} in) — the abrupt size change causes turbulence and pressure loss.`,
partIds: [a.partId, b.partId],
fix: `Add a reducer or match diameters (${nameA} is ${fmt(da, 2)} in, ${nameB} is ${fmt(db, 2)} in)`,
});
}
return findings;
}
// ---------- d. dead legs ----------
function deadLegs(ctx: RuleContext): Finding[] {
const flows = ctx.sim?.flows ?? {};
const flowing = new Set(Object.keys(flows).filter((id) => (flows[id] ?? 0) > 0));
if (!flowing.size) return []; // nothing is flowing — orphan/pump rules apply instead
const findings: Finding[] = [];
for (const part of ctx.parts) {
const conns = ctx.graph.connectorsByPart.get(part.id) ?? [];
if (!conns.length) continue;
if (part.type === 'pump' || isExitPart(part)) continue;
const joinedCount = conns.filter((c) => ctx.graph.joinedKeys.has(c.key)).length;
// Terminal = attached on one side but with an open (capped/dangling) end.
if (joinedCount === 0 || joinedCount === conns.length) continue;
// Walk back through the plain-conduit chain to where the branch attaches.
const chain = [part.id];
let prev: string | null = null;
let cur = part.id;
let attached: string | null = null;
for (let guard = 0; guard < ctx.parts.length; guard++) {
const nbrs = [...(ctx.graph.neighbors.get(cur) ?? [])].filter(
(n) => n !== prev && !chain.includes(n),
);
if (nbrs.length !== 1) break;
const next = nbrs[0];
const nextPart = ctx.partsMap[next];
const isJunction = (ctx.graph.neighbors.get(next)?.size ?? 0) >= 3;
if (!nextPart || nextPart.type === 'pump' || isExitPart(nextPart) || isJunction) {
attached = next;
break;
}
chain.push(next);
prev = cur;
cur = next;
}
const branchFlows =
(attached !== null && flowing.has(attached)) || chain.some((id) => flowing.has(id));
if (!branchFlows) continue;
if (chain.some((id) => isExitPart(ctx.partsMap[id]))) continue;
findings.push({
id: `dead-leg:${part.id}`,
severity: 'warning',
title: 'Dead leg — stagnant water',
detail: `${partName(part)} ends a flowing branch with no reservoir, emitter, drain or grow part — water will sit stagnant (or leak) here.`,
partIds: chain,
fix: 'Cap with an emitter/drain or remove',
});
}
return findings;
}
// ---------- e. unsupported spans ----------
function unsupportedSpans(ctx: RuleContext): Finding[] {
const findings: Finding[] = [];
for (const pipe of ctx.parts) {
if (pipe.type !== 'pipe') continue;
const ends = ctx.graph.connectorsByPart.get(pipe.id) ?? [];
if (ends.length < 2) continue;
const [ea, eb] = [ends[0].pos, ends[ends.length - 1].pos];
if (Math.abs(ea[1] - eb[1]) > 0.3) continue; // not horizontal
const centerY = (ea[1] + eb[1]) / 2;
if (centerY <= SPAN_MIN_HEIGHT_FT) continue;
const length = pipe.params?.length ?? Math.hypot(eb[0] - ea[0], eb[2] - ea[2]);
if (length <= SPAN_MIN_LENGTH_FT) continue;
const supported = (end: [number, number, number]) =>
ctx.parts.some((q) => {
if (q.id === pipe.id) return false;
const box = approxAABB(q, ctx.graph.connectorsByPart.get(q.id) ?? []);
const margin = 0.3;
const horizontallyUnder =
end[0] >= box.min[0] - margin &&
end[0] <= box.max[0] + margin &&
end[2] >= box.min[2] - margin &&
end[2] <= box.max[2] + margin;
// The support must occupy the space just beneath the end: reach up to
// within SUPPORT_REACH_FT of the pipe while extending from below it.
return (
horizontallyUnder &&
box.max[1] >= end[1] - SUPPORT_REACH_FT &&
box.min[1] <= end[1] - SUPPORT_REACH_FT
);
});
if (supported(ea) || supported(eb)) continue;
findings.push({
id: `unsupported-span:${pipe.id}`,
severity: 'info',
title: `Unsupported span ${fmt(length)} ft`,
detail: `${partName(pipe)} runs ${fmt(length)} ft at ${fmt(centerY)} ft height with nothing beneath either end — it will sag over time.`,
partIds: [pipe.id],
fix: 'Add a lattice/structure support',
});
}
return findings;
}
// ---------- f. siphon risk ----------
function siphonRisk(ctx: RuleContext): Finding[] {
const flows = ctx.sim?.flows ?? {};
const findings: Finding[] = [];
for (const pr of ctx.sim?.pumps ?? []) {
if ((pr?.gph ?? 0) <= 0) continue;
const pumpId = pr.pumpId;
const startId = ctx.graph.nodeOfConnector.get(`${pumpId}:out`);
const startNode = startId ? ctx.graph.nodes.get(startId) : undefined;
if (!startNode) continue;
const startY = startNode.pos[1];
let bestDrop = 0;
let bestHighPart = pumpId;
let bestHighY = startY;
const visited = new Set<string>([`P:${pumpId}`, startNode.id]);
const stack: { id: string; pathMax: number; highPart: string }[] = [
{ id: startNode.id, pathMax: startY, highPart: pumpId },
];
while (stack.length) {
const { id, pathMax, highPart } = stack.pop()!;
const node = ctx.graph.nodes.get(id);
if (!node) continue;
for (const e of node.edges) {
if (visited.has(e.to)) continue;
if ((flows[e.partId] ?? 0) <= 0 && e.partId !== pumpId) continue;
visited.add(e.to);
const to = ctx.graph.nodes.get(e.to);
if (!to) continue;
let max = pathMax;
let nextHigh = highPart;
if (to.pos[1] > max) {
max = to.pos[1];
nextHigh = e.partId;
}
const drop = max - to.pos[1];
if (max > startY + 0.5 && drop > bestDrop) {
bestDrop = drop;
bestHighPart = nextHigh;
bestHighY = max;
}
stack.push({ id: e.to, pathMax: max, highPart: nextHigh });
}
}
if (bestDrop >= SIPHON_DROP_FT) {
findings.push({
id: `siphon:${pumpId}`,
severity: 'info',
title: 'Possible siphon on shutdown',
detail: `Flow from ${partName(ctx.partsMap[pumpId])} climbs to ${fmt(bestHighY)} ft and then descends ${fmt(bestDrop)} ft — when the pump stops, gravity can keep siphoning water through the line.`,
partIds: [bestHighPart],
fix: 'Add an air gap or check valve at the high point',
});
}
}
return findings;
}
// ---------- g. undersized drains ----------
function undersizedDrains(ctx: RuleContext): Finding[] {
const findings: Finding[] = [];
for (const part of ctx.parts) {
const isDrain = part.type === 'drain' || typeof part.params?.capacityGph === 'number';
if (!isDrain) continue;
const capacity = part.params?.capacityGph ?? 250;
// Prefer the sim's pre-clamp inflow report; fall back to the flow map.
const reported = ctx.sim?.drains?.find?.((d) => d?.partId === part.id)?.inflowGph;
const inflow = reported ?? ctx.sim?.flows?.[part.id] ?? 0;
if (inflow <= capacity + 0.5) continue;
const needed = Math.ceil(inflow / 25) * 25;
findings.push({
id: `drain-capacity:${part.id}`,
severity: 'error',
title: 'Undersized drain — will overflow',
detail: `${partName(part)} receives ${Math.round(inflow)} GPH but is rated for ${Math.round(capacity)} GPH — the excess will back up and flood.`,
partIds: [part.id],
fix: `Upsize drain to \u2265 ${needed} GPH capacity`,
});
}
return findings;
}
// ---------- h. net-pot spill risk ----------
function netPotSpills(ctx: RuleContext): Finding[] {
const findings: Finding[] = [];
for (const pot of ctx.sim?.netPots ?? []) {
if (!pot?.overflowing) continue;
const part = ctx.partsMap[pot.partId];
if (!part) continue;
const source = ctx.partsMap[pot.sourcePartId];
findings.push({
id: `net-pot-spill:${pot.partId}`,
severity: 'error',
title: 'Net pot will overflow from backed-up plumbing',
detail: `${partName(part)} sits above ${partName(source)}. Water is rising to ${fmt(pot.waterHeightFt)} ft while the pot rim is ${fmt(pot.rimHeightFt)} ft, so nutrient solution will spill out of the top.`,
partIds: source ? [pot.partId, source.id] : [pot.partId],
fix: 'Add a drain/outlet, lower water level, or move the net pot off the flooded line.',
});
}
return findings;
}
// ---------- i. orphans ----------
function orphans(ctx: RuleContext): Finding[] {
const findings: Finding[] = [];
for (const part of ctx.parts) {
const conns = ctx.graph.connectorsByPart.get(part.id) ?? [];
if (!conns.length) continue; // decorative/structural parts can't be orphans
if (conns.some((c) => ctx.graph.joinedKeys.has(c.key))) continue;
findings.push({
id: `orphan:${part.id}`,
severity: 'info',
title: 'Not connected to anything',
detail: `${partName(part)} has no connections — it is not part of any water circuit.`,
partIds: [part.id],
fix: 'Drag it onto a matching connector, or delete it if unused.',
});
}
return findings;
}
// ---------- j. closed valves ----------
function closedValves(ctx: RuleContext): Finding[] {
const findings: Finding[] = [];
for (const valve of ctx.parts) {
if (valve.type !== 'valve' || valve.params?.open !== 0) continue;
const compIdx = ctx.graph.componentOf.get(valve.id);
if (compIdx === undefined) continue;
const comp = ctx.graph.components[compIdx];
const hasLivePump = [...comp].some((id) => {
const p: PlacedPart | undefined = ctx.partsMap[id];
return p?.type === 'pump' && !isPumpOff(p);
});
if (!hasLivePump) continue;
findings.push({
id: `closed-valve:${valve.id}`,
severity: 'info',
title: 'Closed valve blocking a flowing line',
detail: `${partName(valve)} is fully closed — everything beyond it gets no water.`,
partIds: [valve.id],
fix: 'Open the valve, or remove it if the branch is unused.',
});
}
return findings;
}
// ---------- k. environment / room planning ----------
function roomEnvironment(ctx: RuleContext): Finding[] {
const findings: Finding[] = [];
for (const room of ctx.roomMetrics) {
const roomPart = ctx.partsMap[room.roomId];
const width = roomPart?.params.width ?? 0;
const depth = roomPart?.params.depth ?? 0;
const canopyArea = width * depth;
if (!room.sealed && room.co2TankCount > 0) {
findings.push({
id: `room-co2-open:${room.roomId}`,
severity: 'warning',
title: 'CO2 enrichment wasted in open-air room',
detail: `${room.label} is marked open air but has ${room.co2TankCount} CO2 tank${room.co2TankCount === 1 ? '' : 's'} — outside air exchange will purge enrichment before plants can use it.`,
partIds: [room.roomId],
fix: 'Seal the room or remove CO2 hardware.',
});
}
if (room.sealed && room.airChangesPerMinute > 1) {
findings.push({
id: `room-sealed-exhaust:${room.roomId}`,
severity: 'warning',
title: 'Sealed room exhausting too aggressively',
detail: `${room.label} is marked sealed but exhausts ${room.airChangesPerMinute.toFixed(2)} room volumes per minute — that behaves more like an open-air room and dumps conditioned CO2-rich air.`,
partIds: [room.roomId],
fix: 'Reduce exhaust CFM or switch the room to open air.',
});
}
if (!room.sealed && room.airChangesPerMinute < 0.5) {
findings.push({
id: `room-vent-low:${room.roomId}`,
severity: 'info',
title: 'Low air exchange for open-air room',
detail: `${room.label} exchanges only ${room.airChangesPerMinute.toFixed(2)} room volumes per minute — heat and humidity can linger unless intake/exhaust is stronger.`,
partIds: [room.roomId],
fix: 'Increase exhaust capacity or reduce room volume.',
});
}
if (room.sealed && room.co2TankCount === 0 && room.lightWatts >= 600) {
findings.push({
id: `room-co2-missing:${room.roomId}`,
severity: 'info',
title: 'High-light sealed room without CO2',
detail: `${room.label} is sealed and carries ${Math.round(room.lightWatts)} W of lighting but no CO2 source — the room can be enriched, but currently cannot take advantage of the sealed configuration.`,
partIds: [room.roomId],
fix: 'Add a CO2 tank or switch to open-air ventilation.',
});
}
if (canopyArea > 0 && room.lightWatts > 0) {
const wattsPerFt2 = room.lightWatts / canopyArea;
if (wattsPerFt2 < 25) {
findings.push({
id: `room-light-low:${room.roomId}`,
severity: 'info',
title: 'Low lighting density',
detail: `${room.label} provides ${wattsPerFt2.toFixed(1)} W/ft2 across ${canopyArea.toFixed(1)} ft2 — useful for propagation or low-light crops, but light-hungry plants will underperform.`,
partIds: [room.roomId],
fix: 'Add more fixture wattage or reduce active canopy area.',
});
} else if (wattsPerFt2 > 55) {
findings.push({
id: `room-light-high:${room.roomId}`,
severity: 'warning',
title: 'Very high lighting density',
detail: `${room.label} is carrying ${wattsPerFt2.toFixed(1)} W/ft2 — that is intense enough to demand strong cooling, CO2 strategy, and careful canopy distance management.`,
partIds: [room.roomId],
fix: 'Raise fixtures, reduce wattage, or tighten environmental control.',
});
}
}
// New environment/lighting findings
if (room.lightWatts > 0) {
if (room.roomDLI < 10) {
findings.push({
id: `room-dli-low:${room.roomId}`,
severity: 'warning',
title: 'Daily Light Integral (DLI) is too low',
detail: `${room.label} active DLI is ${room.roomDLI.toFixed(1)} — DLI < 10 is insufficient for healthy growth and will cause the crop to stretch.`,
partIds: [room.roomId],
fix: 'Increase grow lights wattage, increase photoperiod (hours on), or decrease room/canopy area.',
});
} else if (room.roomDLI > 45) {
findings.push({
id: `room-dli-high:${room.roomId}`,
severity: 'warning',
title: 'Daily Light Integral (DLI) is too high',
detail: `${room.label} active DLI is ${room.roomDLI.toFixed(1)} — DLI > 45 risks phototoxic bleaching and leaf tissue damage.`,
partIds: [room.roomId],
fix: 'Reduce grow lights wattage, decrease photoperiod (hours on), or raise the fixtures.',
});
}
}
if (room.sealed && room.lightWatts > 400 && room.roomCo2 === 150) {
findings.push({
id: `room-co2-starved:${room.roomId}`,
severity: 'warning',
title: 'Sealed room CO₂ starvation risk',
detail: `${room.label} is sealed and runs high-wattage lighting (${room.lightWatts.toFixed(0)} W > 400 W), but CO₂ levels drop to 150 PPM due to lack of a CO₂ tank.`,
partIds: [room.roomId],
fix: 'Add a CO₂ tank or switch to open-air ventilation.',
});
}
const growthStage = useBuilder.getState().growthStage ?? 'vegetative';
let minVpd = 0.8;
let maxVpd = 1.6;
if (growthStage === 'seedling') {
minVpd = 0.4;
maxVpd = 0.8;
} else if (growthStage === 'vegetative') {
minVpd = 0.8;
maxVpd = 1.2;
} else if (growthStage === 'flowering') {
minVpd = 1.2;
maxVpd = 1.6;
}
if (room.roomVPD < minVpd) {
findings.push({
id: `room-vpd-low:${room.roomId}`,
severity: 'warning',
title: 'Vapor Pressure Deficit (VPD) is too low',
detail: `${room.label} VPD is ${room.roomVPD.toFixed(2)} kPa, which is below the optimal range of ${minVpd}-${maxVpd} kPa for the ${growthStage} stage. Low VPD slows transpiration and can cause nutrient deficiencies.`,
partIds: [room.roomId],
fix: 'Raise the temperature, lower relative humidity, or increase exhaust ventilation.',
});
} else if (room.roomVPD > maxVpd) {
findings.push({
id: `room-vpd-high:${room.roomId}`,
severity: 'warning',
title: 'Vapor Pressure Deficit (VPD) is too high',
detail: `${room.label} VPD is ${room.roomVPD.toFixed(2)} kPa, which is above the optimal range of ${minVpd}-${maxVpd} kPa for the ${growthStage} stage. High VPD causes excessive transpiration, leading to plant stress and wilting.`,
partIds: [room.roomId],
fix: 'Lower the temperature, increase relative humidity, or reduce lighting intensity.',
});
}
}
return findings;
}
// ---------- registry ----------
/** All design-check rules, in execution order. Add new rules here. */
export const ALL_RULES: ValidationRule[] = [
{
id: 'pump-head',
description: 'Pumps should keep ≥20% head margin below their max head.',
run: pumpHeadMargin,
},
{
id: 'pump-dead',
description: 'Pumps that are off, starved of source water, or dead-ended.',
run: pumpStarvedOrOff,
},
{
id: 'diameter-mismatch',
description: 'Joined connectors whose parts have different pipe diameters.',
run: mismatchedDiameters,
},
{
id: 'dead-leg',
description: 'Flowing branches that terminate with no exit part (stagnation).',
run: deadLegs,
},
{
id: 'unsupported-span',
description: 'Long elevated horizontal pipes with nothing beneath their ends.',
run: unsupportedSpans,
},
{
id: 'siphon',
description: 'Flow paths that climb then descend ≥2 ft (siphon on shutdown).',
run: siphonRisk,
},
{
id: 'drain-capacity',
description: 'Drains receiving more flow than their rated capacity.',
run: undersizedDrains,
},
{
id: 'net-pot-spill',
description: 'Net pots positioned over backed-up wet lines that will spill from the rim.',
run: netPotSpills,
},
{
id: 'orphan',
description: 'Flow-capable parts with no connections at all.',
run: orphans,
},
{
id: 'closed-valve',
description: 'Fully closed valves on an otherwise powered circuit.',
run: closedValves,
},
{
id: 'room-environment',
description: 'Grow-room ventilation, sealed/open-air behavior, CO2 use, and light density.',
run: roomEnvironment,
},
{
id: 'reservoir-chemistry',
description: 'Reservoir temperature, pH, and EC/nutrient concentration values.',
run: reservoirWaterChemistry,
},
{
id: 'part-overlap',
description: 'Parts that are overlapping or morphing into one another.',
run: partOverlaps,
},
];
function reservoirWaterChemistry(ctx: RuleContext): Finding[] {
const findings: Finding[] = [];
const reservoirs = ctx.parts.filter((p) => p.type === 'reservoir');
for (const res of reservoirs) {
const stats = computeReservoirWaterStats(res, ctx.partsMap);
const runtime = ctx.sim?.reservoirRuntime?.[res.id];
if (runtime?.empty) {
findings.push({
id: `res-empty:${res.id}`,
severity: 'error',
title: 'Reservoir runs dry under current load',
detail: `${res.label || `Reservoir #${res.id}`} is fully depleted by the current pump/drain demand, so downstream flow stops entirely.`,
partIds: [res.id],
fix: 'Increase reservoir volume, reduce draw rate, or close the loop with return flow.',
});
} else if (runtime && runtime.minutesRemaining !== null && runtime.minutesRemaining < 30) {
findings.push({
id: `res-runtime-low:${res.id}`,
severity: 'warning',
title: 'Reservoir runtime is too short',
detail: `${res.label || `Reservoir #${res.id}`} has only ${runtime.minutesRemaining.toFixed(1)} minutes of water left at the current net draw of ${runtime.netOutflowGph.toFixed(1)} GPH.`,
partIds: [res.id],
fix: 'Increase source volume, reduce pump flow, or return more water to this reservoir.',
});
}
// 1. Water Temperature check
if (stats.tempF > 73.0) {
findings.push({
id: `res-temp-high:${res.id}`,
severity: 'warning',
title: 'Reservoir water temperature too high',
detail: `${res.label || `Reservoir #${res.id}`} water temperature is ${stats.tempF.toFixed(1)}°F — water above 73°F holds significantly less dissolved oxygen and increases Pythium (root rot) risks.`,
partIds: [res.id],
fix: 'Add a water chiller, reduce room ambient temperature, or turn off unneeded inline pumps.',
});
}
// 2. pH check
if (stats.ph < 5.5) {
findings.push({
id: `res-ph-low:${res.id}`,
severity: 'warning',
title: 'Water pH is too low (acidic)',
detail: `${res.label || `Reservoir #${res.id}`} pH is ${stats.ph.toFixed(1)} — pH below 5.5 locks out Calcium, Magnesium, and Phosphorus, starving the plant.`,
partIds: [res.id],
fix: 'Add pH Up (potassium hydroxide) or dilute with fresh water to raise pH to 5.8 - 6.2.',
});
} else if (stats.ph > 6.5) {
findings.push({
id: `res-ph-high:${res.id}`,
severity: 'warning',
title: 'Water pH is too high (alkaline)',
detail: `${res.label || `Reservoir #${res.id}`} pH is ${stats.ph.toFixed(1)} — pH above 6.5 locks out Iron, Manganese, Boron, and Zinc, causing leaf chlorosis.`,
partIds: [res.id],
fix: 'Add pH Down (phosphoric/citric acid) to lower pH to 5.8 - 6.2.',
});
}
// 3. EC check
const growthStage = useBuilder.getState().growthStage;
let ecLow = 1.0;
let ecHigh = 1.6;
let fixLow = 'Add more concentrated nutrient solution (part A/B) to raise EC to 1.0 - 1.6 mS/cm.';
let fixHigh = 'Dilute the reservoir with fresh water to reduce nutrient concentration (EC).';
if (growthStage === 'seedling') {
ecLow = 0.4;
ecHigh = 0.8;
fixLow = 'Add dilute nutrient solution to raise EC to 0.4 - 0.8 mS/cm for seedlings.';
fixHigh = 'Dilute the reservoir with fresh water to lower EC to 0.4 - 0.8 mS/cm for seedlings.';
} else if (growthStage === 'flowering') {
ecLow = 1.5;
ecHigh = 2.2;
fixLow = 'Add bloom nutrients to raise EC to 1.5 - 2.2 mS/cm for flowering.';
fixHigh = 'Dilute the reservoir with fresh water to lower EC to 1.5 - 2.2 mS/cm for flowering.';
}
if (stats.ec < ecLow) {
findings.push({
id: `res-ec-low:${res.id}`,
severity: 'info',
title: 'Low nutrient concentration (EC)',
detail: `${res.label || `Reservoir #${res.id}`} EC is ${stats.ec.toFixed(1)} mS/cm — very low nutrient levels will limit growth rate and lead to deficiencies in the ${growthStage} stage.`,
partIds: [res.id],
fix: fixLow,
});
} else if (stats.ec > ecHigh) {
findings.push({
id: `res-ec-high:${res.id}`,
severity: 'warning',
title: 'High nutrient concentration (EC)',
detail: `${res.label || `Reservoir #${res.id}`} EC is ${stats.ec.toFixed(1)} mS/cm — EC above ${ecHigh.toFixed(1)} risks nutrient burn or root dehydration in the ${growthStage} stage.`,
partIds: [res.id],
fix: fixHigh,
});
}
}
return findings;
}
function partOverlaps(ctx: RuleContext): Finding[] {
const findings: Finding[] = [];
const seen = new Set<string>();
for (let i = 0; i < ctx.parts.length; i++) {
const p1 = ctx.parts[i];
for (let j = i + 1; j < ctx.parts.length; j++) {
const p2 = ctx.parts[j];
// Calculate distance between centers
const dist = dist3(p1.position, p2.position);
// If centers are extremely close (e.g. < 0.15 ft) and they occupy the same space
if (dist < 0.15) {
const key = [p1.id, p2.id].sort().join(':');
if (seen.has(key)) continue;
seen.add(key);
findings.push({
id: `part-overlap:${key}`,
severity: 'warning',
title: 'Overlapping parts (clipping)',
detail: `${partName(p1)} and ${partName(p2)} are occupying the same space and morphing into each other.`,
partIds: [p1.id, p2.id],
fix: 'Delete the duplicate part or move them apart',
});
} else if (p1.type === 'pipe' && p2.type === 'pipe') {
// Check for partial collinear overlap of two parallel pipes
const yaw1 = p1.rotation[1];
const yaw2 = p2.rotation[1];
const angleDiff = Math.abs(Math.atan2(Math.sin(yaw1 - yaw2), Math.cos(yaw1 - yaw2)));
const isParallel = angleDiff < 0.05 || Math.abs(angleDiff - Math.PI) < 0.05;
if (isParallel) {
const dirX = Math.cos(yaw1);
const dirZ = -Math.sin(yaw1);
const dx = p2.position[0] - p1.position[0];
const dy = p2.position[1] - p1.position[1];
const dz = p2.position[2] - p1.position[2];
// Project distance along pipe vector and find lateral offset
const proj = dx * dirX + dz * dirZ;
const latX = dx - proj * dirX;
const latZ = dz - proj * dirZ;
const latDist = Math.hypot(latX, dy, latZ);
if (latDist < 0.12) {
const len1 = p1.params.length ?? 2;
const len2 = p2.params.length ?? 2;
const maxAllowedDist = (len1 + len2) / 2 - 0.05;
if (dist < maxAllowedDist - 0.2) {
const key = [p1.id, p2.id].sort().join(':');
if (seen.has(key)) continue;
seen.add(key);
findings.push({
id: `part-overlap:${key}`,
severity: 'warning',
title: 'Collinear pipes overlapping',
detail: `${partName(p1)} and ${partName(p2)} are collinear but overlapping by ${(maxAllowedDist - dist).toFixed(1)} ft, causing them to morph together.`,
partIds: [p1.id, p2.id],
fix: 'Adjust their positions or delete one',
});
}
}
}
}
}
}
return findings;
}

294
src/validation/selftest.ts Normal file
View File

@@ -0,0 +1,294 @@
/**
* Design Check self-test — run with: npx tsx src/validation/selftest.ts
*
* Builds synthetic part layouts that exercise every rule and asserts the
* expected findings fire (and that healthy scenes stay clean).
*
* Geometry notes: connectors join when world positions are < CONNECT_DIST
* (0.35 ft) apart. Rotation [0,0,π/2] turns a pipe vertical (local -x end
* down); [0,-π/2,0] turns it along +z.
*/
import type { PartType, PlacedPart, Vec3 } from '../types';
import { PART_DEFS } from '../parts/catalog';
import { validateSystem } from './designCheck';
import type { Finding, FindingSeverity } from './types';
import { applyReservoirRuntime, simulate } from '../simulation/flowSimulator';
const Z90: Vec3 = [0, 0, Math.PI / 2]; // pipe vertical, 'a' end at bottom
const Yn90: Vec3 = [0, -Math.PI / 2, 0]; // pipe along z, 'a' end at -z
let id = 0;
function P(
type: PartType,
position: Vec3,
params: Record<string, number> = {},
rotation: Vec3 = [0, 0, 0],
): PlacedPart {
return {
id: `${type}${++id}`,
type,
position,
rotation,
params: { ...PART_DEFS[type].defaults, ...params },
};
}
/** reservoir -> pump chain: reservoir 'b' @ [1,.25,0] joins pump 'in'. */
const source = (pumpParams: Record<string, number> = {}): PlacedPart[] => [
P('reservoir', [0, 0, 0]),
P('pump', [1.45, 0, 0], pumpParams), // in @ [1,.25,0], out @ [1.9,.25,0]
];
// ---------- tiny test harness ----------
let passed = 0;
let failed = 0;
function check(rule: string, name: string, ok: boolean, findings?: Finding[]) {
if (ok) {
passed++;
console.log(` PASS [${rule}] ${name}`);
} else {
failed++;
console.log(` FAIL [${rule}] ${name}`);
if (findings) {
for (const f of findings) console.log(` -> ${f.severity} ${f.id}: ${f.title}`);
}
}
}
const has = (fs: Finding[], prefix: string, severity?: FindingSeverity) =>
fs.some((f) => f.id.startsWith(prefix) && (severity === undefined || f.severity === severity));
console.log('Design Check self-test\n');
// ---------- a. pump head margin ----------
{
// 7 ft riser on an 8 ft max-head pump => 91% of max head -> warning.
const parts = [
...source({ gph: 400, maxHeadFt: 8 }),
P('pipe', [1.9, 3.75, 0], { length: 7 }, Z90), // [1.9,.25,0] .. [1.9,7.25,0]
P('emitter', [1.7, 7.25, 0]),
];
const fs = validateSystem(parts);
check('pump-head', 'riser at 91% of max head -> warning', has(fs, 'pump-head:', 'warning'), fs);
check('pump-dead', 'near-limit pump still flows -> no starved error', !has(fs, 'pump-dead:'), fs);
}
{
// 9 ft riser exceeds the 8 ft max head -> error (and no duplicate starved error).
const parts = [
...source({ gph: 400, maxHeadFt: 8 }),
P('pipe', [1.9, 4.75, 0], { length: 9 }, Z90), // top @ 9.25 ft
P('emitter', [1.7, 9.25, 0]),
];
const fs = validateSystem(parts);
check('pump-head', 'riser above max head -> error', has(fs, 'pump-head:', 'error'), fs);
check('pump-dead', 'over-head pump owned by head rule -> no starved error', !has(fs, 'pump-dead:'), fs);
}
// ---------- b. pump starved / off ----------
{
const parts = [
...source({ on: 0 }),
P('pipe', [2.9, 0.25, 0], { length: 2 }),
P('emitter', [3.7, 0.25, 0]),
];
const fs = validateSystem(parts);
const f = fs.find((x) => x.id.startsWith('pump-dead:'));
check('pump-dead', 'switched-off pump -> error', f?.severity === 'error', fs);
check('pump-dead', 'off reason mentioned', /off/i.test(f?.detail ?? ''), fs);
}
{
// No reservoir anywhere -> starved (inlet not connected).
const parts = [
P('pump', [0, 0, 0]), // out @ [0.45,.25,0]
P('pipe', [1.45, 0.25, 0], { length: 2 }),
P('emitter', [2.25, 0.25, 0]),
];
const fs = validateSystem(parts);
const f = fs.find((x) => x.id.startsWith('pump-dead:'));
check('pump-dead', 'no water source -> error', f?.severity === 'error', fs);
check('pump-dead', 'inlet reason mentioned', /inlet/i.test(f?.detail ?? ''), fs);
}
{
// Reservoir + pump, outlet dangling -> starved (nothing downstream).
const fs = validateSystem(source());
const f = fs.find((x) => x.id.startsWith('pump-dead:'));
check('pump-dead', 'nothing downstream -> error', f?.severity === 'error', fs);
check('pump-dead', 'outlet reason mentioned', /outlet/i.test(f?.detail ?? ''), fs);
}
// ---------- c. mismatched diameters ----------
{
const parts = [
P('pipe', [0, 1, 0], { length: 2, diameter: 1 }), // b @ [1,1,0]
P('pipe', [2, 1, 0], { length: 2, diameter: 2 }), // a @ [1,1,0]
];
const fs = validateSystem(parts);
check('diameter-mismatch', '1 in joined to 2 in -> warning', has(fs, 'diameter-mismatch:', 'warning'), fs);
check('diameter-mismatch', 'no other findings in mismatch scene', fs.length === 1, fs);
}
{
const parts = [
P('pipe', [0, 1, 0], { length: 2, diameter: 1 }),
P('pipe', [2, 1, 0], { length: 2, diameter: 1 }),
];
const fs = validateSystem(parts);
check('diameter-mismatch', 'matching diameters -> clean', !has(fs, 'diameter-mismatch:'), fs);
}
// ---------- d. dead legs ----------
{
// Tee splits: main run ends in an emitter, side branch ends open.
const branch = P('pipe', [2.5, 0.25, 1.6], { length: 2 }, Yn90); // [2.5,.25,.6]..[2.5,.25,2.6]
const parts = [
...source(),
P('tee', [2.5, 0.25, 0]), // a @ [1.9,.25,0], b @ [3.1,.25,0], c @ [2.5,.25,.6]
P('pipe', [4.1, 0.25, 0], { length: 2 }), // b @ [5.1,.25,0]
P('emitter', [4.9, 0.25, 0]),
branch,
];
const fs = validateSystem(parts);
const f = fs.find((x) => x.id === `dead-leg:${branch.id}`);
check('dead-leg', 'open side branch off a flowing tee -> warning', f?.severity === 'warning', fs);
check('dead-leg', 'dead leg points at the branch pipe', f?.partIds.includes(branch.id) === true, fs);
}
// ---------- e. unsupported spans ----------
{
const fs = validateSystem([P('pipe', [0, 2, 0], { length: 6 })]);
check('unsupported-span', '6 ft pipe at 2 ft, nothing beneath -> info', has(fs, 'unsupported-span:', 'info'), fs);
}
{
const parts = [
P('pipe', [0, 2, 0], { length: 6 }),
P('lattice', [-3, 0, 0], { width: 2.5, height: 3 }),
P('lattice', [3, 0, 0], { width: 2.5, height: 3 }),
];
const fs = validateSystem(parts);
check('unsupported-span', 'same span with lattices beneath -> clean', !has(fs, 'unsupported-span:'), fs);
}
{
const fs = validateSystem([P('pipe', [0, 1, 0], { length: 6 })]);
check('unsupported-span', 'long pipe below 1.5 ft -> clean', !has(fs, 'unsupported-span:'), fs);
}
// ---------- f. siphon risk ----------
{
// Up 4 ft, across, then down 3 ft to an emitter -> siphon tip.
const parts = [
...source(),
P('pipe', [1.9, 2.25, 0], { length: 4 }, Z90), // riser to [1.9,4.25,0]
P('pipe', [2.9, 4.25, 0], { length: 2 }), // across to [3.9,4.25,0]
P('pipe', [3.9, 2.75, 0], { length: 3 }, Z90), // down to [3.9,1.25,0]
P('emitter', [3.7, 1.25, 0]),
];
const fs = validateSystem(parts);
check('siphon', 'climb then 3 ft descent -> info', has(fs, 'siphon:', 'info'), fs);
}
// ---------- g. undersized drain ----------
{
const drain = P('drain', [4.25, 0, 0], { capacityGph: 100 }); // in @ [3.9,.25,0]
const parts = [
...source({ gph: 600 }),
P('pipe', [2.9, 0.25, 0], { length: 2 }),
drain,
];
const fs = validateSystem(parts);
const f = fs.find((x) => x.id === `drain-capacity:${drain.id}`);
check('drain-capacity', '~535 GPH into a 100 GPH drain -> error', f?.severity === 'error', fs);
check('drain-capacity', 'fix suggests an upsized capacity', /\d+ GPH/.test(f?.fix ?? ''), fs);
}
// ---------- h. orphans ----------
{
const lone = P('pipe', [10, 0.5, 10], { length: 2 });
const parts = [
...source(),
P('pipe', [2.9, 0.25, 0], { length: 2 }),
P('emitter', [3.7, 0.25, 0]),
lone,
];
const fs = validateSystem(parts);
check('orphan', 'disconnected pipe -> info', has(fs, `orphan:${lone.id}`, 'info'), fs);
check('orphan', 'connected parts not flagged', fs.filter((f) => f.id.startsWith('orphan:')).length === 1, fs);
}
// ---------- i. finite source runtime ----------
{
const realNow = Date.now;
let fakeNow = 1_000_000;
Date.now = () => fakeNow;
const parts: PlacedPart[] = [
{ ...P('reservoir', [0, 0, 0], { width: 1, depth: 1, height: 1, level: 10 }), id: 'res-runtime' },
{ ...P('pump', [1.45, 0, 0], { gph: 400, maxHeadFt: 8, on: 1 }), id: 'pump-runtime' },
P('pipe', [2.9, 0.25, 0], { length: 2 }),
{ ...P('drain', [4.25, 0, 0], { capacityGph: 1000 }), id: 'drain-runtime' },
];
const partsMap = Object.fromEntries(parts.map((p) => [p.id, p])) as Record<string, PlacedPart>;
const base = simulate(partsMap);
const start = applyReservoirRuntime(base, partsMap, true, 0);
fakeNow += 3_600_000;
const drained = applyReservoirRuntime(base, partsMap, true, 1);
Date.now = realNow;
const fs = validateSystem(parts, drained);
check('reservoir-runtime', 'finite source starts with runtime < 30 min', (start.reservoirRuntime['res-runtime']?.minutesRemaining ?? 999) < 30, fs);
check('reservoir-runtime', 'source drains fully after long run', drained.reservoirRuntime['res-runtime']?.empty === true, fs);
check('reservoir-runtime', 'dry source stops pump flow', drained.pumps[0]?.gph === 0 && drained.totalGph === 0, fs);
check('reservoir-runtime', 'design check reports empty reservoir', has(fs, 'res-empty:', 'error'), fs);
}
// ---------- j. closed valve ----------
{
const valve = P('valve', [2.25, 0.25, 0], { open: 0 }); // a @ [1.9,.25,0], b @ [2.6,.25,0]
const parts = [
...source(),
valve,
P('pipe', [3.6, 0.25, 0], { length: 2 }),
P('emitter', [4.4, 0.25, 0]),
];
const fs = validateSystem(parts);
check('closed-valve', 'closed valve on a powered circuit -> info', has(fs, `closed-valve:${valve.id}`, 'info'), fs);
}
// ---------- k. part overlap ----------
{
// Two pipes directly on top of each other
const parts = [
P('pipe', [0, 0, 0], { length: 2 }),
P('pipe', [0.05, 0, 0.05], { length: 2 }),
];
const fs = validateSystem(parts);
check('part-overlap', 'overlapping pipes -> warning', has(fs, 'part-overlap:', 'warning'), fs);
}
{
// Two collinear pipes overlapping
const parts = [
P('pipe', [0, 0, 0], { length: 2 }),
P('pipe', [1.5, 0, 0], { length: 2 }), // perfectly end-to-end is 2.0. At 1.5 they overlap by 0.5 ft.
];
const fs = validateSystem(parts);
check('part-overlap', 'collinear overlapping pipes -> warning', has(fs, 'part-overlap:', 'warning'), fs);
}
// ---------- healthy scenes ----------
{
const parts = [
...source({ gph: 300, maxHeadFt: 8 }),
P('pipe', [2.9, 0.25, 0], { length: 2 }),
P('emitter', [3.7, 0.25, 0]),
];
const fs = validateSystem(parts).filter((f) => !f.id.startsWith('res-temp-high:'));
check('healthy', 'reservoir -> pump -> pipe -> emitter -> zero findings', fs.length === 0, fs);
}
{
const fs = validateSystem([]);
check('healthy', 'empty scene -> zero findings', fs.length === 0, fs);
}
// ---------- summary ----------
console.log(`\n${passed} passed, ${failed} failed`);
// The project has no @types/node, so reach process via globalThis.
const proc = (globalThis as { process?: { exitCode?: number } }).process;
if (failed > 0 && proc) proc.exitCode = 1;

55
src/validation/types.ts Normal file
View File

@@ -0,0 +1,55 @@
import type { PlacedPart, SimResult } from '../types';
import type { RoomMetrics } from '../environment/roomMetrics';
import type { SystemGraph } from './graph';
/**
* Validation (Design Check) types.
*
* Severity philosophy:
* - `error` — the system won't work or will flood (no flow, overflow).
* - `warning` — it will work, but badly (stagnation, turbulence, near-limit).
* - `info` — good-practice tips (supports, check valves, tidiness).
*/
export type FindingSeverity = 'error' | 'warning' | 'info';
/** One issue discovered by the Design Check. */
export interface Finding {
/** Stable id, unique within one validation run (e.g. "dead-leg:ab12cd34"). */
id: string;
severity: FindingSeverity;
/** Short headline shown on the finding card. */
title: string;
/** One-to-two sentence explanation of what is wrong and why it matters. */
detail: string;
/** Ids of the offending part(s) — used for "Show part" navigation. */
partIds: string[];
/** Suggested fix, when one can be recommended. */
fix?: string;
}
/** Everything a rule may inspect. Built once per validation run. */
export interface RuleContext {
/** All placed parts, keyed by id. */
partsMap: Record<string, PlacedPart>;
/** All placed parts as an array (same objects as `partsMap`). */
parts: PlacedPart[];
/**
* Simulation result for the current build. May be missing if the simulator
* threw — rules must use optional chaining and degrade gracefully.
*/
sim: SimResult | undefined;
/** Connectivity graph built independently of the simulator. */
graph: SystemGraph;
/** Derived grow-room air and equipment metrics. */
roomMetrics: RoomMetrics[];
}
/** A single registered design-check rule. */
export interface ValidationRule {
/** Rule id — used as the prefix of every finding id it emits. */
id: string;
/** Human description (what the rule checks). */
description: string;
run: (ctx: RuleContext) => Finding[];
}

11
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,11 @@
/// <reference types="vite/client" />
import React from 'react';
declare module 'react' {
namespace JSX {
interface IntrinsicElements {
'stripe-buy-button': any;
}
}
}